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 emergency: bool,
202 pub recording: bool,
204 pub reason: Option<String>,
206 pub start_time: f64,
208 pub units: Vec<u32>,
210 pub patched_talkgroups: Vec<u32>,
213}
214
215#[derive(Clone, Debug, Default, Serialize, Deserialize)]
217#[serde(default)]
218pub struct ConcludedCall {
219 pub path: String,
222 pub system: u16,
224 pub call: CallRecord,
226 pub files: CallFiles,
227}
228
229#[derive(Clone, Debug, Default, Serialize, Deserialize)]
230#[serde(default)]
231pub struct CallFiles {
232 pub json: PathBuf,
233 pub wav: PathBuf,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub m4a: Option<PathBuf>,
240}
241
242#[derive(Clone, Debug, Default, Serialize, Deserialize)]
244#[serde(default)]
245pub struct CallRecord {
246 pub call_num: u64,
247 pub short_name: String,
248 pub talkgroup: u32,
249 pub talkgroup_tag: String,
250 pub talkgroup_description: String,
251 pub talkgroup_group_tag: String,
252 pub talkgroup_group: String,
253 pub freq: u64,
255 pub start_time: i64,
257 pub stop_time: i64,
258 pub call_length: f64,
260 #[serde(deserialize_with = "flag")]
261 pub emergency: bool,
262 #[serde(deserialize_with = "flag")]
263 pub encrypted: bool,
264 pub priority: i64,
265 #[serde(deserialize_with = "flag")]
266 pub phase2_tdma: bool,
267 pub tdma_slot: i64,
268 pub audio_type: String,
270 #[serde(rename = "freqList")]
271 pub freq_list: Vec<FreqEntry>,
272 #[serde(rename = "srcList")]
273 pub src_list: Vec<SrcEntry>,
274 #[serde(skip_serializing_if = "Vec::is_empty")]
277 pub patched_talkgroups: Vec<u32>,
278 #[serde(flatten)]
279 pub extra: Map<String, Value>,
280}
281
282impl CallRecord {
283 pub fn error_count(&self) -> u64 {
285 self.freq_list.iter().map(|f| f.error_count).sum()
286 }
287 pub fn spike_count(&self) -> u64 {
288 self.freq_list.iter().map(|f| f.spike_count).sum()
289 }
290}
291
292#[derive(Clone, Debug, Default, Serialize, Deserialize)]
293#[serde(default)]
294pub struct FreqEntry {
295 pub freq: u64,
296 pub time: i64,
298 pub pos: f64,
300 pub len: f64,
302 pub error_count: u64,
303 pub spike_count: u64,
304 #[serde(flatten)]
305 pub extra: Map<String, Value>,
306}
307
308#[derive(Clone, Debug, Default, Serialize, Deserialize)]
310#[serde(default)]
311pub struct SrcEntry {
312 pub src: i64,
314 pub time: i64,
316 pub pos: f64,
318 #[serde(deserialize_with = "flag")]
319 pub emergency: bool,
320 pub signal_system: String,
321 pub tag: String,
323 pub tag_ota: String,
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub lat: Option<f64>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub lon: Option<f64>,
330 #[serde(flatten)]
331 pub extra: Map<String, Value>,
332}
333
334fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
336 Ok(match Value::deserialize(d)? {
337 Value::Bool(b) => b,
338 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
339 Value::String(s) => s == "true" || s == "1",
340 _ => false,
341 })
342}
343
344#[derive(Clone, Debug, Default, Serialize, Deserialize)]
346#[serde(default)]
347pub struct UnitEvent {
348 pub system: u16,
350 pub short_name: String,
351 pub kind: String,
355 pub unit: u32,
357 pub talkgroup: Option<u32>,
360 pub time: f64,
362}
363
364#[derive(Clone, Debug, Default, Serialize, Deserialize)]
366#[serde(default)]
367pub struct AudioChunk {
368 pub call_id: u32,
369 pub system: u16,
371 pub short_name: String,
372 pub talkgroup: u32,
373 pub sample_rate: u32,
374 pub pcm: String,
375}
376
377impl AudioChunk {
378 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
379 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
380 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
381 }
382 pub fn samples(&self) -> Vec<i16> {
384 let b = base64::decode(&self.pcm);
385 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
386 }
387}
388
389#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390#[serde(default)]
391pub struct Status {
392 pub time: f64,
394 pub systems: Vec<SystemStatus>,
395}
396
397#[derive(Clone, Debug, Default, Serialize, Deserialize)]
398#[serde(default)]
399pub struct SystemStatus {
400 pub index: u16,
401 pub short_name: String,
402 pub control_channel_hz: Option<u64>,
404 pub decode_rate: f64,
406 pub active_calls: u32,
407 pub recording: u32,
408}
409
410#[derive(Clone, Debug, Default, Serialize, Deserialize)]
411#[serde(default)]
412pub struct Shutdown {
413 pub grace_s: f64,
415}
416
417#[derive(Clone, Debug, Serialize, Deserialize)]
420#[serde(tag = "type")]
421pub enum PluginMessage {
422 #[serde(rename = "ready")]
424 Ready,
425 #[serde(rename = "log")]
426 Log { level: Level, message: String },
427 #[serde(rename = "status")]
429 Status {
430 state: State,
431 #[serde(default)]
432 message: String,
433 },
434 #[serde(rename = "call.result")]
436 CallResult {
437 path: String,
438 outcome: Outcome,
439 #[serde(default, skip_serializing_if = "String::is_empty")]
440 message: String,
441 #[serde(default, skip_serializing_if = "String::is_empty")]
443 url: String,
444 },
445 #[serde(rename = "metrics")]
449 Metrics(Metrics),
450 #[serde(other)]
451 Unknown,
452}
453
454#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
457#[serde(default, rename_all = "camelCase")]
458pub struct Metrics {
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub queued: Option<u64>,
462 #[serde(skip_serializing_if = "Option::is_none")]
464 pub retrying: Option<u64>,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub in_flight: Option<u64>,
468 #[serde(skip_serializing_if = "Option::is_none")]
470 pub retries: Option<u64>,
471 #[serde(skip_serializing_if = "Option::is_none")]
473 pub bytes_sent: Option<u64>,
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub latency_ms: Option<f64>,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub last_ok: Option<f64>,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub last_error: Option<f64>,
482 #[serde(skip_serializing_if = "String::is_empty")]
483 pub last_error_text: String,
484 #[serde(skip_serializing_if = "Vec::is_empty")]
486 pub endpoints: Vec<Endpoint>,
487 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
489 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
490}
491
492#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
494#[serde(default, rename_all = "camelCase")]
495pub struct Endpoint {
496 pub name: String,
498 pub state: EndpointState,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 pub latency_ms: Option<f64>,
501 #[serde(skip_serializing_if = "Option::is_none")]
502 pub last_ok: Option<f64>,
503 #[serde(skip_serializing_if = "String::is_empty")]
504 pub last_error: String,
505}
506
507#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "lowercase")]
509pub enum EndpointState {
510 #[default]
511 Unknown,
512 Up,
514 Degraded,
516 Down,
518}
519
520#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
521#[serde(rename_all = "lowercase")]
522pub enum Level {
523 Error,
524 Warn,
525 Info,
526 Debug,
527}
528
529#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "lowercase")]
531pub enum State {
532 Ok,
534 Warning,
536 Error,
538}
539
540#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
541#[serde(rename_all = "lowercase")]
542pub enum Outcome {
543 Ok,
545 Skipped,
547 Failed,
549}
550
551pub mod base64 {
553 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
554
555 pub fn encode(b: &[u8]) -> String {
556 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
557 for c in b.chunks(3) {
558 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
559 for i in 0..4 {
560 if i <= c.len() {
561 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
562 } else {
563 o.push('=');
564 }
565 }
566 }
567 o
568 }
569
570 pub fn decode(s: &str) -> Vec<u8> {
572 let mut o = Vec::with_capacity(s.len() / 4 * 3);
573 let (mut acc, mut bits) = (0u32, 0);
574 for ch in s.bytes() {
575 let v = match ch {
576 b'A'..=b'Z' => ch - b'A',
577 b'a'..=b'z' => ch - b'a' + 26,
578 b'0'..=b'9' => ch - b'0' + 52,
579 b'+' => 62,
580 b'/' => 63,
581 _ => continue,
582 };
583 acc = acc << 6 | v as u32;
584 bits += 6;
585 if bits >= 8 {
586 bits -= 8;
587 o.push((acc >> bits) as u8);
588 }
589 }
590 o
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn base64_roundtrip() {
600 for n in 0..10 {
601 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
602 assert_eq!(base64::decode(&base64::encode(&b)), b);
603 }
604 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
605 }
606
607 #[test]
608 fn unknown_types_and_fields_are_ignored() {
609 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
610 assert!(matches!(m, HostMessage::Unknown));
611 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
612 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
613 }
614
615 #[test]
616 fn call_record_reads_trunk_recorder_json() {
617 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
618 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
619 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
620 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
621 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
622 let c: CallRecord = serde_json::from_str(j).unwrap();
623 assert!(c.encrypted && !c.emergency);
624 assert_eq!(c.patched_talkgroups, [101, 65001]);
625 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
626 assert_eq!(c.src_list[0].tag_ota, "E1");
627 assert_eq!(c.extra["color_code"], -1);
628 let v = serde_json::to_value(&c).unwrap();
630 assert_eq!(v["color_code"], -1);
631 assert_eq!(v["srcList"][0]["src"], 1234);
632 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
633 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
634 assert!(unpatched.get("patched_talkgroups").is_none());
635 }
636}