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 #[serde(skip_serializing_if = "String::is_empty")]
197 pub site: String,
198 pub talkgroup: u32,
199 pub talkgroup_tag: String,
201 pub freq_hz: u64,
202 pub tdma_slot: Option<u8>,
204 pub analog: bool,
205 pub encrypted: bool,
206 pub algid: Option<u8>,
209 pub key_id: Option<u16>,
210 pub decrypted: bool,
212 pub emergency: bool,
213 pub duplex: bool,
216 pub packet: bool,
217 pub priority: u8,
218 pub recording: bool,
220 pub reason: Option<String>,
222 pub start_time: f64,
224 pub units: Vec<u32>,
226 pub sources: Vec<CallSource>,
229 pub patched_talkgroups: Vec<u32>,
232}
233
234#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
238#[serde(default)]
239pub struct CallSource {
240 pub unit: u32,
241 pub time: f64,
243 pub emergency: bool,
244 pub encrypted: bool,
245 pub duplex: bool,
247 pub packet: bool,
249 pub priority: u8,
251}
252
253#[derive(Clone, Debug, Default, Serialize, Deserialize)]
255#[serde(default)]
256pub struct ConcludedCall {
257 pub path: String,
260 pub system: u16,
262 pub call: CallRecord,
264 pub files: CallFiles,
265}
266
267#[derive(Clone, Debug, Default, Serialize, Deserialize)]
268#[serde(default)]
269pub struct CallFiles {
270 pub json: PathBuf,
271 pub wav: PathBuf,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub m4a: Option<PathBuf>,
278}
279
280#[derive(Clone, Debug, Default, Serialize, Deserialize)]
282#[serde(default)]
283pub struct CallRecord {
284 pub call_num: u64,
285 pub short_name: String,
286 #[serde(skip_serializing_if = "String::is_empty")]
289 pub site: String,
290 pub talkgroup: u32,
291 pub talkgroup_tag: String,
292 pub talkgroup_description: String,
293 pub talkgroup_group_tag: String,
294 pub talkgroup_group: String,
295 pub freq: u64,
297 pub start_time: i64,
299 pub stop_time: i64,
300 pub call_length: f64,
302 #[serde(deserialize_with = "flag")]
303 pub emergency: bool,
304 #[serde(deserialize_with = "flag")]
305 pub encrypted: bool,
306 #[serde(deserialize_with = "flag")]
309 pub decrypted: bool,
310 pub priority: i64,
311 #[serde(deserialize_with = "flag")]
312 pub phase2_tdma: bool,
313 pub tdma_slot: i64,
314 pub audio_type: String,
316 #[serde(rename = "freqList")]
317 pub freq_list: Vec<FreqEntry>,
318 #[serde(rename = "srcList")]
319 pub src_list: Vec<SrcEntry>,
320 #[serde(skip_serializing_if = "Vec::is_empty")]
323 pub patched_talkgroups: Vec<u32>,
324 #[serde(flatten)]
325 pub extra: Map<String, Value>,
326}
327
328impl CallRecord {
329 pub fn error_count(&self) -> u64 {
331 self.freq_list.iter().map(|f| f.error_count).sum()
332 }
333 pub fn spike_count(&self) -> u64 {
334 self.freq_list.iter().map(|f| f.spike_count).sum()
335 }
336}
337
338#[derive(Clone, Debug, Default, Serialize, Deserialize)]
339#[serde(default)]
340pub struct FreqEntry {
341 pub freq: u64,
342 pub time: i64,
344 pub pos: f64,
346 pub len: f64,
348 pub error_count: u64,
349 pub spike_count: u64,
350 #[serde(flatten)]
351 pub extra: Map<String, Value>,
352}
353
354#[derive(Clone, Debug, Default, Serialize, Deserialize)]
356#[serde(default)]
357pub struct SrcEntry {
358 pub src: i64,
360 pub time: i64,
362 pub pos: f64,
364 #[serde(deserialize_with = "flag")]
367 pub emergency: bool,
368 #[serde(deserialize_with = "flag")]
369 pub encrypted: bool,
370 #[serde(deserialize_with = "flag")]
371 pub duplex: bool,
372 #[serde(deserialize_with = "flag")]
374 pub mode: bool,
375 pub priority: i64,
377 pub signal_system: String,
378 pub tag: String,
380 pub tag_ota: String,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub lat: Option<f64>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub lon: Option<f64>,
387 #[serde(flatten)]
388 pub extra: Map<String, Value>,
389}
390
391fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
393 Ok(match Value::deserialize(d)? {
394 Value::Bool(b) => b,
395 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
396 Value::String(s) => s == "true" || s == "1",
397 _ => false,
398 })
399}
400
401#[derive(Clone, Debug, Default, Serialize, Deserialize)]
403#[serde(default)]
404pub struct UnitEvent {
405 pub system: u16,
407 pub short_name: String,
408 #[serde(skip_serializing_if = "String::is_empty")]
410 pub site: String,
411 pub kind: String,
415 pub unit: u32,
417 pub talkgroup: Option<u32>,
420 pub emergency: bool,
423 pub time: f64,
425}
426
427#[derive(Clone, Debug, Default, Serialize, Deserialize)]
429#[serde(default)]
430pub struct AudioChunk {
431 pub call_id: u32,
432 pub system: u16,
434 pub short_name: String,
435 pub talkgroup: u32,
436 pub sample_rate: u32,
437 pub pcm: String,
438}
439
440impl AudioChunk {
441 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
442 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
443 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
444 }
445 pub fn samples(&self) -> Vec<i16> {
447 let b = base64::decode(&self.pcm);
448 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
449 }
450}
451
452#[derive(Clone, Debug, Default, Serialize, Deserialize)]
453#[serde(default)]
454pub struct Status {
455 pub time: f64,
457 pub systems: Vec<SystemStatus>,
458}
459
460#[derive(Clone, Debug, Default, Serialize, Deserialize)]
461#[serde(default)]
462pub struct SystemStatus {
463 pub index: u16,
464 pub short_name: String,
465 pub site_name: String,
468 pub control_channel_hz: Option<u64>,
470 pub decode_rate: f64,
472 pub active_calls: u32,
473 pub recording: u32,
474 #[serde(skip_serializing_if = "Option::is_none")]
478 pub nac: Option<u32>,
479 #[serde(skip_serializing_if = "Option::is_none")]
480 pub wacn: Option<u32>,
481 #[serde(skip_serializing_if = "Option::is_none")]
482 pub sys_id: Option<u32>,
483 #[serde(skip_serializing_if = "Option::is_none")]
484 pub rfss: Option<u32>,
485 #[serde(skip_serializing_if = "Option::is_none")]
486 pub site: Option<u32>,
487}
488
489#[derive(Clone, Debug, Default, Serialize, Deserialize)]
490#[serde(default)]
491pub struct Shutdown {
492 pub grace_s: f64,
494}
495
496#[derive(Clone, Debug, Serialize, Deserialize)]
499#[serde(tag = "type")]
500pub enum PluginMessage {
501 #[serde(rename = "ready")]
503 Ready,
504 #[serde(rename = "log")]
505 Log { level: Level, message: String },
506 #[serde(rename = "status")]
508 Status {
509 state: State,
510 #[serde(default)]
511 message: String,
512 },
513 #[serde(rename = "call.result")]
515 CallResult {
516 path: String,
517 outcome: Outcome,
518 #[serde(default, skip_serializing_if = "String::is_empty")]
519 message: String,
520 #[serde(default, skip_serializing_if = "String::is_empty")]
522 url: String,
523 },
524 #[serde(rename = "metrics")]
528 Metrics(Metrics),
529 #[serde(other)]
530 Unknown,
531}
532
533#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
536#[serde(default, rename_all = "camelCase")]
537pub struct Metrics {
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub queued: Option<u64>,
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub retrying: Option<u64>,
544 #[serde(skip_serializing_if = "Option::is_none")]
546 pub in_flight: Option<u64>,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub retries: Option<u64>,
550 #[serde(skip_serializing_if = "Option::is_none")]
552 pub bytes_sent: Option<u64>,
553 #[serde(skip_serializing_if = "Option::is_none")]
555 pub latency_ms: Option<f64>,
556 #[serde(skip_serializing_if = "Option::is_none")]
558 pub last_ok: Option<f64>,
559 #[serde(skip_serializing_if = "Option::is_none")]
560 pub last_error: Option<f64>,
561 #[serde(skip_serializing_if = "String::is_empty")]
562 pub last_error_text: String,
563 #[serde(skip_serializing_if = "Vec::is_empty")]
565 pub endpoints: Vec<Endpoint>,
566 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
568 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
569}
570
571#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
573#[serde(default, rename_all = "camelCase")]
574pub struct Endpoint {
575 pub name: String,
577 pub state: EndpointState,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub latency_ms: Option<f64>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub last_ok: Option<f64>,
582 #[serde(skip_serializing_if = "String::is_empty")]
583 pub last_error: String,
584}
585
586#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
587#[serde(rename_all = "lowercase")]
588pub enum EndpointState {
589 #[default]
590 Unknown,
591 Up,
593 Degraded,
595 Down,
597}
598
599#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
600#[serde(rename_all = "lowercase")]
601pub enum Level {
602 Error,
603 Warn,
604 Info,
605 Debug,
606}
607
608#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
609#[serde(rename_all = "lowercase")]
610pub enum State {
611 Ok,
613 Warning,
615 Error,
617}
618
619#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
620#[serde(rename_all = "lowercase")]
621pub enum Outcome {
622 Ok,
624 Skipped,
626 Failed,
628}
629
630pub mod base64 {
632 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
633
634 pub fn encode(b: &[u8]) -> String {
635 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
636 for c in b.chunks(3) {
637 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
638 for i in 0..4 {
639 if i <= c.len() {
640 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
641 } else {
642 o.push('=');
643 }
644 }
645 }
646 o
647 }
648
649 pub fn decode(s: &str) -> Vec<u8> {
651 let mut o = Vec::with_capacity(s.len() / 4 * 3);
652 let (mut acc, mut bits) = (0u32, 0);
653 for ch in s.bytes() {
654 let v = match ch {
655 b'A'..=b'Z' => ch - b'A',
656 b'a'..=b'z' => ch - b'a' + 26,
657 b'0'..=b'9' => ch - b'0' + 52,
658 b'+' => 62,
659 b'/' => 63,
660 _ => continue,
661 };
662 acc = acc << 6 | v as u32;
663 bits += 6;
664 if bits >= 8 {
665 bits -= 8;
666 o.push((acc >> bits) as u8);
667 }
668 }
669 o
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn base64_roundtrip() {
679 for n in 0..10 {
680 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
681 assert_eq!(base64::decode(&base64::encode(&b)), b);
682 }
683 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
684 }
685
686 #[test]
687 fn unknown_types_and_fields_are_ignored() {
688 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
689 assert!(matches!(m, HostMessage::Unknown));
690 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
691 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
692 }
693
694 #[test]
695 fn call_record_reads_trunk_recorder_json() {
696 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
697 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
698 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
699 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
700 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
701 let c: CallRecord = serde_json::from_str(j).unwrap();
702 assert!(c.encrypted && !c.emergency);
703 assert_eq!(c.patched_talkgroups, [101, 65001]);
704 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
705 assert_eq!(c.src_list[0].tag_ota, "E1");
706 assert_eq!(c.extra["color_code"], -1);
707 let v = serde_json::to_value(&c).unwrap();
709 assert_eq!(v["color_code"], -1);
710 assert_eq!(v["srcList"][0]["src"], 1234);
711 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
712 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
713 assert!(unpatched.get("patched_talkgroups").is_none());
714 }
715
716 #[test]
717 fn system_identity_is_there_once_stated() {
718 let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
720 assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
721 let v = serde_json::to_value(&s).unwrap();
723 assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
724 let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
725 let v = serde_json::to_value(&s).unwrap();
726 assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
727 }
728}