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 {
45 pub const M4A: &str = "m4a";
47}
48
49#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
51#[serde(default)]
52pub struct Manifest {
53 pub id: String,
55 pub name: String,
57 pub version: String,
59 pub description: String,
60 pub api: u32,
62 pub subscribe: Vec<String>,
64 pub audio_formats: Vec<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub config: Option<Value>,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub system_config: Option<Value>,
74 #[serde(skip_serializing_if = "String::is_empty")]
75 pub homepage: String,
76 #[serde(skip_serializing_if = "String::is_empty")]
77 pub repository: String,
78 #[serde(skip_serializing_if = "Vec::is_empty")]
79 pub authors: Vec<String>,
80 #[serde(skip_serializing_if = "String::is_empty")]
81 pub license: String,
82}
83
84impl Manifest {
85 pub fn subscribes(&self, topic: &str) -> bool {
86 self.subscribe.iter().any(|t| t == topic)
87 }
88 pub fn wants_format(&self, format: &str) -> bool {
89 self.audio_formats.iter().any(|f| f == format)
90 }
91}
92
93#[allow(clippy::large_enum_variant)]
97#[derive(Clone, Debug, Serialize, Deserialize)]
98#[serde(tag = "type")]
99pub enum HostMessage {
100 #[serde(rename = "hello")]
102 Hello(Hello),
103 #[serde(rename = "call.start")]
104 CallStart(CallInfo),
105 #[serde(rename = "call.end")]
106 CallEnd(CallInfo),
107 #[serde(rename = "call.concluded")]
108 CallConcluded(ConcludedCall),
109 #[serde(rename = "unit")]
110 Unit(UnitEvent),
111 #[serde(rename = "audio")]
112 Audio(AudioChunk),
113 #[serde(rename = "status")]
114 Status(Status),
115 #[serde(rename = "shutdown")]
119 Shutdown(Shutdown),
120 #[serde(other)]
122 Unknown,
123}
124
125#[derive(Clone, Debug, Default, Serialize, Deserialize)]
126#[serde(default)]
127pub struct Hello {
128 pub api: u32,
130 pub host: HostInfo,
131 pub config: Value,
133 pub systems: Vec<SystemInfo>,
135 pub capture_dir: PathBuf,
137 pub data_dir: PathBuf,
139 pub audio_formats: Vec<String>,
142}
143
144#[derive(Clone, Debug, Default, Serialize, Deserialize)]
145#[serde(default)]
146pub struct HostInfo {
147 pub name: String,
148 pub version: String,
149}
150
151pub const CONVENTIONAL: u16 = 65535;
155
156#[derive(Clone, Debug, Default, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SystemInfo {
159 pub index: u16,
163 pub short_name: String,
167 pub kind: String,
169 pub config: Value,
172}
173
174#[derive(Clone, Debug, Default, Serialize, Deserialize)]
176#[serde(default)]
177pub struct CallInfo {
178 pub id: u32,
180 pub system: u16,
182 pub short_name: String,
183 pub talkgroup: u32,
184 pub talkgroup_tag: String,
186 pub freq_hz: u64,
187 pub tdma_slot: Option<u8>,
189 pub analog: bool,
190 pub encrypted: bool,
191 pub emergency: bool,
192 pub recording: bool,
194 pub reason: Option<String>,
196 pub start_time: f64,
198 pub units: Vec<u32>,
200 pub patched_talkgroups: Vec<u32>,
203}
204
205#[derive(Clone, Debug, Default, Serialize, Deserialize)]
207#[serde(default)]
208pub struct ConcludedCall {
209 pub path: String,
212 pub system: u16,
214 pub call: CallRecord,
216 pub files: CallFiles,
217}
218
219#[derive(Clone, Debug, Default, Serialize, Deserialize)]
220#[serde(default)]
221pub struct CallFiles {
222 pub json: PathBuf,
223 pub wav: PathBuf,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub m4a: Option<PathBuf>,
228}
229
230#[derive(Clone, Debug, Default, Serialize, Deserialize)]
232#[serde(default)]
233pub struct CallRecord {
234 pub call_num: u64,
235 pub short_name: String,
236 pub talkgroup: u32,
237 pub talkgroup_tag: String,
238 pub talkgroup_description: String,
239 pub talkgroup_group_tag: String,
240 pub talkgroup_group: String,
241 pub freq: u64,
243 pub start_time: i64,
245 pub stop_time: i64,
246 pub call_length: f64,
248 #[serde(deserialize_with = "flag")]
249 pub emergency: bool,
250 #[serde(deserialize_with = "flag")]
251 pub encrypted: bool,
252 pub priority: i64,
253 #[serde(deserialize_with = "flag")]
254 pub phase2_tdma: bool,
255 pub tdma_slot: i64,
256 pub audio_type: String,
258 #[serde(rename = "freqList")]
259 pub freq_list: Vec<FreqEntry>,
260 #[serde(rename = "srcList")]
261 pub src_list: Vec<SrcEntry>,
262 #[serde(skip_serializing_if = "Vec::is_empty")]
265 pub patched_talkgroups: Vec<u32>,
266 #[serde(flatten)]
267 pub extra: Map<String, Value>,
268}
269
270impl CallRecord {
271 pub fn error_count(&self) -> u64 {
273 self.freq_list.iter().map(|f| f.error_count).sum()
274 }
275 pub fn spike_count(&self) -> u64 {
276 self.freq_list.iter().map(|f| f.spike_count).sum()
277 }
278}
279
280#[derive(Clone, Debug, Default, Serialize, Deserialize)]
281#[serde(default)]
282pub struct FreqEntry {
283 pub freq: u64,
284 pub time: i64,
286 pub pos: f64,
288 pub len: f64,
290 pub error_count: u64,
291 pub spike_count: u64,
292 #[serde(flatten)]
293 pub extra: Map<String, Value>,
294}
295
296#[derive(Clone, Debug, Default, Serialize, Deserialize)]
298#[serde(default)]
299pub struct SrcEntry {
300 pub src: i64,
302 pub time: i64,
304 pub pos: f64,
306 #[serde(deserialize_with = "flag")]
307 pub emergency: bool,
308 pub signal_system: String,
309 pub tag: String,
311 pub tag_ota: String,
313 #[serde(flatten)]
314 pub extra: Map<String, Value>,
315}
316
317fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
319 Ok(match Value::deserialize(d)? {
320 Value::Bool(b) => b,
321 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
322 Value::String(s) => s == "true" || s == "1",
323 _ => false,
324 })
325}
326
327#[derive(Clone, Debug, Default, Serialize, Deserialize)]
329#[serde(default)]
330pub struct UnitEvent {
331 pub system: u16,
333 pub short_name: String,
334 pub kind: String,
337 pub unit: u32,
339 pub talkgroup: Option<u32>,
341 pub time: f64,
343}
344
345#[derive(Clone, Debug, Default, Serialize, Deserialize)]
347#[serde(default)]
348pub struct AudioChunk {
349 pub call_id: u32,
350 pub system: u16,
352 pub short_name: String,
353 pub talkgroup: u32,
354 pub sample_rate: u32,
355 pub pcm: String,
356}
357
358impl AudioChunk {
359 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
360 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
361 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
362 }
363 pub fn samples(&self) -> Vec<i16> {
365 let b = base64::decode(&self.pcm);
366 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
367 }
368}
369
370#[derive(Clone, Debug, Default, Serialize, Deserialize)]
371#[serde(default)]
372pub struct Status {
373 pub time: f64,
375 pub systems: Vec<SystemStatus>,
376}
377
378#[derive(Clone, Debug, Default, Serialize, Deserialize)]
379#[serde(default)]
380pub struct SystemStatus {
381 pub index: u16,
382 pub short_name: String,
383 pub control_channel_hz: Option<u64>,
385 pub decode_rate: f64,
387 pub active_calls: u32,
388 pub recording: u32,
389}
390
391#[derive(Clone, Debug, Default, Serialize, Deserialize)]
392#[serde(default)]
393pub struct Shutdown {
394 pub grace_s: f64,
396}
397
398#[derive(Clone, Debug, Serialize, Deserialize)]
401#[serde(tag = "type")]
402pub enum PluginMessage {
403 #[serde(rename = "ready")]
405 Ready,
406 #[serde(rename = "log")]
407 Log { level: Level, message: String },
408 #[serde(rename = "status")]
410 Status {
411 state: State,
412 #[serde(default)]
413 message: String,
414 },
415 #[serde(rename = "call.result")]
417 CallResult {
418 path: String,
419 outcome: Outcome,
420 #[serde(default, skip_serializing_if = "String::is_empty")]
421 message: String,
422 #[serde(default, skip_serializing_if = "String::is_empty")]
424 url: String,
425 },
426 #[serde(rename = "metrics")]
430 Metrics(Metrics),
431 #[serde(other)]
432 Unknown,
433}
434
435#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
438#[serde(default, rename_all = "camelCase")]
439pub struct Metrics {
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub queued: Option<u64>,
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub retrying: Option<u64>,
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub in_flight: Option<u64>,
449 #[serde(skip_serializing_if = "Option::is_none")]
451 pub retries: Option<u64>,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub bytes_sent: Option<u64>,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub latency_ms: Option<f64>,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 pub last_ok: Option<f64>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub last_error: Option<f64>,
463 #[serde(skip_serializing_if = "String::is_empty")]
464 pub last_error_text: String,
465 #[serde(skip_serializing_if = "Vec::is_empty")]
467 pub endpoints: Vec<Endpoint>,
468 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
470 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
471}
472
473#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
475#[serde(default, rename_all = "camelCase")]
476pub struct Endpoint {
477 pub name: String,
479 pub state: EndpointState,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub latency_ms: Option<f64>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub last_ok: Option<f64>,
484 #[serde(skip_serializing_if = "String::is_empty")]
485 pub last_error: String,
486}
487
488#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
489#[serde(rename_all = "lowercase")]
490pub enum EndpointState {
491 #[default]
492 Unknown,
493 Up,
495 Degraded,
497 Down,
499}
500
501#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
502#[serde(rename_all = "lowercase")]
503pub enum Level {
504 Error,
505 Warn,
506 Info,
507 Debug,
508}
509
510#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "lowercase")]
512pub enum State {
513 Ok,
515 Warning,
517 Error,
519}
520
521#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
522#[serde(rename_all = "lowercase")]
523pub enum Outcome {
524 Ok,
526 Skipped,
528 Failed,
530}
531
532pub mod base64 {
534 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
535
536 pub fn encode(b: &[u8]) -> String {
537 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
538 for c in b.chunks(3) {
539 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
540 for i in 0..4 {
541 if i <= c.len() {
542 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
543 } else {
544 o.push('=');
545 }
546 }
547 }
548 o
549 }
550
551 pub fn decode(s: &str) -> Vec<u8> {
553 let mut o = Vec::with_capacity(s.len() / 4 * 3);
554 let (mut acc, mut bits) = (0u32, 0);
555 for ch in s.bytes() {
556 let v = match ch {
557 b'A'..=b'Z' => ch - b'A',
558 b'a'..=b'z' => ch - b'a' + 26,
559 b'0'..=b'9' => ch - b'0' + 52,
560 b'+' => 62,
561 b'/' => 63,
562 _ => continue,
563 };
564 acc = acc << 6 | v as u32;
565 bits += 6;
566 if bits >= 8 {
567 bits -= 8;
568 o.push((acc >> bits) as u8);
569 }
570 }
571 o
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn base64_roundtrip() {
581 for n in 0..10 {
582 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
583 assert_eq!(base64::decode(&base64::encode(&b)), b);
584 }
585 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
586 }
587
588 #[test]
589 fn unknown_types_and_fields_are_ignored() {
590 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
591 assert!(matches!(m, HostMessage::Unknown));
592 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
593 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
594 }
595
596 #[test]
597 fn call_record_reads_trunk_recorder_json() {
598 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
599 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
600 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
601 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
602 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
603 let c: CallRecord = serde_json::from_str(j).unwrap();
604 assert!(c.encrypted && !c.emergency);
605 assert_eq!(c.patched_talkgroups, [101, 65001]);
606 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
607 assert_eq!(c.src_list[0].tag_ota, "E1");
608 assert_eq!(c.extra["color_code"], -1);
609 let v = serde_json::to_value(&c).unwrap();
611 assert_eq!(v["color_code"], -1);
612 assert_eq!(v["srcList"][0]["src"], 1234);
613 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
614 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
615 assert!(unpatched.get("patched_talkgroups").is_none());
616 }
617}