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
156pub const CONVENTIONAL: u16 = 65535;
160
161#[derive(Clone, Debug, Default, Serialize, Deserialize)]
162#[serde(default)]
163pub struct SystemInfo {
164 pub index: u16,
168 pub short_name: String,
172 pub kind: String,
174 pub config: Value,
177 #[serde(skip_serializing_if = "Value::is_null")]
181 pub settings: Value,
182}
183
184#[derive(Clone, Debug, Default, Serialize, Deserialize)]
186#[serde(default)]
187pub struct CallInfo {
188 pub id: u32,
190 pub system: u16,
192 pub short_name: String,
193 pub talkgroup: u32,
194 pub talkgroup_tag: String,
196 pub freq_hz: u64,
197 pub tdma_slot: Option<u8>,
199 pub analog: bool,
200 pub encrypted: bool,
201 pub algid: Option<u8>,
204 pub key_id: Option<u16>,
205 pub decrypted: bool,
207 pub emergency: bool,
208 pub duplex: bool,
211 pub packet: bool,
212 pub priority: u8,
213 pub recording: bool,
215 pub reason: Option<String>,
217 pub start_time: f64,
219 pub units: Vec<u32>,
221 pub sources: Vec<CallSource>,
224 pub patched_talkgroups: Vec<u32>,
227}
228
229#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
233#[serde(default)]
234pub struct CallSource {
235 pub unit: u32,
236 pub time: f64,
238 pub emergency: bool,
239 pub encrypted: bool,
240 pub duplex: bool,
242 pub packet: bool,
244 pub priority: u8,
246}
247
248#[derive(Clone, Debug, Default, Serialize, Deserialize)]
250#[serde(default)]
251pub struct ConcludedCall {
252 pub path: String,
255 pub system: u16,
257 pub call: CallRecord,
259 pub files: CallFiles,
260}
261
262#[derive(Clone, Debug, Default, Serialize, Deserialize)]
263#[serde(default)]
264pub struct CallFiles {
265 pub json: PathBuf,
266 pub wav: PathBuf,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub m4a: Option<PathBuf>,
273}
274
275#[derive(Clone, Debug, Default, Serialize, Deserialize)]
277#[serde(default)]
278pub struct CallRecord {
279 pub call_num: u64,
280 pub short_name: String,
281 pub talkgroup: u32,
282 pub talkgroup_tag: String,
283 pub talkgroup_description: String,
284 pub talkgroup_group_tag: String,
285 pub talkgroup_group: String,
286 pub freq: u64,
288 pub start_time: i64,
290 pub stop_time: i64,
291 pub call_length: f64,
293 #[serde(deserialize_with = "flag")]
294 pub emergency: bool,
295 #[serde(deserialize_with = "flag")]
296 pub encrypted: bool,
297 #[serde(deserialize_with = "flag")]
300 pub decrypted: bool,
301 pub priority: i64,
302 #[serde(deserialize_with = "flag")]
303 pub phase2_tdma: bool,
304 pub tdma_slot: i64,
305 pub audio_type: String,
307 #[serde(rename = "freqList")]
308 pub freq_list: Vec<FreqEntry>,
309 #[serde(rename = "srcList")]
310 pub src_list: Vec<SrcEntry>,
311 #[serde(skip_serializing_if = "Vec::is_empty")]
314 pub patched_talkgroups: Vec<u32>,
315 #[serde(flatten)]
316 pub extra: Map<String, Value>,
317}
318
319impl CallRecord {
320 pub fn error_count(&self) -> u64 {
322 self.freq_list.iter().map(|f| f.error_count).sum()
323 }
324 pub fn spike_count(&self) -> u64 {
325 self.freq_list.iter().map(|f| f.spike_count).sum()
326 }
327}
328
329#[derive(Clone, Debug, Default, Serialize, Deserialize)]
330#[serde(default)]
331pub struct FreqEntry {
332 pub freq: u64,
333 pub time: i64,
335 pub pos: f64,
337 pub len: f64,
339 pub error_count: u64,
340 pub spike_count: u64,
341 #[serde(flatten)]
342 pub extra: Map<String, Value>,
343}
344
345#[derive(Clone, Debug, Default, Serialize, Deserialize)]
347#[serde(default)]
348pub struct SrcEntry {
349 pub src: i64,
351 pub time: i64,
353 pub pos: f64,
355 #[serde(deserialize_with = "flag")]
358 pub emergency: bool,
359 #[serde(deserialize_with = "flag")]
360 pub encrypted: bool,
361 #[serde(deserialize_with = "flag")]
362 pub duplex: bool,
363 #[serde(deserialize_with = "flag")]
365 pub mode: bool,
366 pub priority: i64,
368 pub signal_system: String,
369 pub tag: String,
371 pub tag_ota: String,
373 #[serde(skip_serializing_if = "Option::is_none")]
375 pub lat: Option<f64>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub lon: Option<f64>,
378 #[serde(flatten)]
379 pub extra: Map<String, Value>,
380}
381
382fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
384 Ok(match Value::deserialize(d)? {
385 Value::Bool(b) => b,
386 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
387 Value::String(s) => s == "true" || s == "1",
388 _ => false,
389 })
390}
391
392#[derive(Clone, Debug, Default, Serialize, Deserialize)]
394#[serde(default)]
395pub struct UnitEvent {
396 pub system: u16,
398 pub short_name: String,
399 pub kind: String,
403 pub unit: u32,
405 pub talkgroup: Option<u32>,
408 pub emergency: bool,
411 pub time: f64,
413}
414
415#[derive(Clone, Debug, Default, Serialize, Deserialize)]
417#[serde(default)]
418pub struct AudioChunk {
419 pub call_id: u32,
420 pub system: u16,
422 pub short_name: String,
423 pub talkgroup: u32,
424 pub sample_rate: u32,
425 pub pcm: String,
426}
427
428impl AudioChunk {
429 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
430 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
431 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
432 }
433 pub fn samples(&self) -> Vec<i16> {
435 let b = base64::decode(&self.pcm);
436 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
437 }
438}
439
440#[derive(Clone, Debug, Default, Serialize, Deserialize)]
441#[serde(default)]
442pub struct Status {
443 pub time: f64,
445 pub systems: Vec<SystemStatus>,
446}
447
448#[derive(Clone, Debug, Default, Serialize, Deserialize)]
449#[serde(default)]
450pub struct SystemStatus {
451 pub index: u16,
452 pub short_name: String,
453 pub control_channel_hz: Option<u64>,
455 pub decode_rate: f64,
457 pub active_calls: u32,
458 pub recording: u32,
459 #[serde(skip_serializing_if = "Option::is_none")]
463 pub nac: Option<u32>,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub wacn: Option<u32>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub sys_id: Option<u32>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub rfss: Option<u32>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub site: Option<u32>,
472}
473
474#[derive(Clone, Debug, Default, Serialize, Deserialize)]
475#[serde(default)]
476pub struct Shutdown {
477 pub grace_s: f64,
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize)]
484#[serde(tag = "type")]
485pub enum PluginMessage {
486 #[serde(rename = "ready")]
488 Ready,
489 #[serde(rename = "log")]
490 Log { level: Level, message: String },
491 #[serde(rename = "status")]
493 Status {
494 state: State,
495 #[serde(default)]
496 message: String,
497 },
498 #[serde(rename = "call.result")]
500 CallResult {
501 path: String,
502 outcome: Outcome,
503 #[serde(default, skip_serializing_if = "String::is_empty")]
504 message: String,
505 #[serde(default, skip_serializing_if = "String::is_empty")]
507 url: String,
508 },
509 #[serde(rename = "metrics")]
513 Metrics(Metrics),
514 #[serde(other)]
515 Unknown,
516}
517
518#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
521#[serde(default, rename_all = "camelCase")]
522pub struct Metrics {
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub queued: Option<u64>,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub retrying: Option<u64>,
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub in_flight: Option<u64>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub retries: Option<u64>,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub bytes_sent: Option<u64>,
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub latency_ms: Option<f64>,
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub last_ok: Option<f64>,
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub last_error: Option<f64>,
546 #[serde(skip_serializing_if = "String::is_empty")]
547 pub last_error_text: String,
548 #[serde(skip_serializing_if = "Vec::is_empty")]
550 pub endpoints: Vec<Endpoint>,
551 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
553 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
554}
555
556#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
558#[serde(default, rename_all = "camelCase")]
559pub struct Endpoint {
560 pub name: String,
562 pub state: EndpointState,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 pub latency_ms: Option<f64>,
565 #[serde(skip_serializing_if = "Option::is_none")]
566 pub last_ok: Option<f64>,
567 #[serde(skip_serializing_if = "String::is_empty")]
568 pub last_error: String,
569}
570
571#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
572#[serde(rename_all = "lowercase")]
573pub enum EndpointState {
574 #[default]
575 Unknown,
576 Up,
578 Degraded,
580 Down,
582}
583
584#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
585#[serde(rename_all = "lowercase")]
586pub enum Level {
587 Error,
588 Warn,
589 Info,
590 Debug,
591}
592
593#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
594#[serde(rename_all = "lowercase")]
595pub enum State {
596 Ok,
598 Warning,
600 Error,
602}
603
604#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
605#[serde(rename_all = "lowercase")]
606pub enum Outcome {
607 Ok,
609 Skipped,
611 Failed,
613}
614
615pub mod base64 {
617 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
618
619 pub fn encode(b: &[u8]) -> String {
620 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
621 for c in b.chunks(3) {
622 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
623 for i in 0..4 {
624 if i <= c.len() {
625 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
626 } else {
627 o.push('=');
628 }
629 }
630 }
631 o
632 }
633
634 pub fn decode(s: &str) -> Vec<u8> {
636 let mut o = Vec::with_capacity(s.len() / 4 * 3);
637 let (mut acc, mut bits) = (0u32, 0);
638 for ch in s.bytes() {
639 let v = match ch {
640 b'A'..=b'Z' => ch - b'A',
641 b'a'..=b'z' => ch - b'a' + 26,
642 b'0'..=b'9' => ch - b'0' + 52,
643 b'+' => 62,
644 b'/' => 63,
645 _ => continue,
646 };
647 acc = acc << 6 | v as u32;
648 bits += 6;
649 if bits >= 8 {
650 bits -= 8;
651 o.push((acc >> bits) as u8);
652 }
653 }
654 o
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn base64_roundtrip() {
664 for n in 0..10 {
665 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
666 assert_eq!(base64::decode(&base64::encode(&b)), b);
667 }
668 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
669 }
670
671 #[test]
672 fn unknown_types_and_fields_are_ignored() {
673 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
674 assert!(matches!(m, HostMessage::Unknown));
675 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
676 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
677 }
678
679 #[test]
680 fn call_record_reads_trunk_recorder_json() {
681 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
682 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
683 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
684 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
685 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
686 let c: CallRecord = serde_json::from_str(j).unwrap();
687 assert!(c.encrypted && !c.emergency);
688 assert_eq!(c.patched_talkgroups, [101, 65001]);
689 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
690 assert_eq!(c.src_list[0].tag_ota, "E1");
691 assert_eq!(c.extra["color_code"], -1);
692 let v = serde_json::to_value(&c).unwrap();
694 assert_eq!(v["color_code"], -1);
695 assert_eq!(v["srcList"][0]["src"], 1234);
696 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
697 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
698 assert!(unpatched.get("patched_talkgroups").is_none());
699 }
700
701 #[test]
702 fn system_identity_is_there_once_stated() {
703 let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
705 assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
706 let v = serde_json::to_value(&s).unwrap();
708 assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
709 let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
710 let v = serde_json::to_value(&s).unwrap();
711 assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
712 }
713}