Skip to main content

trunk_recorder_plugin/
protocol.rs

1//! The wire protocol: JSON lines.
2//!
3//! The recorder starts a plugin's executable with no arguments and writes one
4//! [`HostMessage`] per line to its stdin; the plugin writes one
5//! [`PluginMessage`] per line to its stdout. stderr is the plugin's log.
6//! Either side ignores message types and fields it doesn't know, so both can
7//! grow within an [`API_VERSION`].
8//!
9//! `plugin --describe` prints the plugin's [`Manifest`] (one JSON object) and exits.
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::{Map, Value};
15
16/// The protocol version. A plugin states the version it was built for in its
17/// manifest; the recorder runs plugins whose `api` it supports.
18pub const API_VERSION: u32 = 1;
19
20/// Exit status of a plugin that can't run with its configuration: the recorder
21/// shows the error and doesn't restart it until recording next starts.
22/// (Any other exit is a crash, and the plugin is restarted with backoff.)
23pub const EXIT_CONFIG: i32 = 78;
24
25/// What a plugin can subscribe to ([`Manifest::subscribe`]).
26pub mod topic {
27    /// A call started ([`super::CallInfo`]).
28    pub const CALL_START: &str = "call.start";
29    /// A call ended ([`super::CallInfo`]); its files follow in `call.concluded`.
30    pub const CALL_END: &str = "call.end";
31    /// A recorded call's files are on disk ([`super::ConcludedCall`]).
32    pub const CALL_CONCLUDED: &str = "call.concluded";
33    /// A radio registered, affiliated, … ([`super::UnitEvent`]).
34    pub const UNIT: &str = "unit";
35    /// Live audio of recording calls ([`super::AudioChunk`]).
36    pub const AUDIO: &str = "audio";
37    /// Every system's state, every few seconds ([`super::Status`]).
38    pub const STATUS: &str = "status";
39
40    pub const ALL: &[&str] = &[CALL_START, CALL_END, CALL_CONCLUDED, UNIT, AUDIO, STATUS];
41}
42
43/// Audio formats a plugin can ask for ([`Manifest::audio_formats`]). A plugin
44/// that asks for none gets the WAV.
45pub mod format {
46    /// AAC in an MP4 container (OpenMHz, Broadcastify Calls).
47    pub const M4A: &str = "m4a";
48    /// The WAV file as well as the M4A: with an .m4a made, the WAV is
49    /// otherwise written only when it's kept.
50    pub const WAV: &str = "wav";
51}
52
53/// Who a plugin is and what it wants — printed by `plugin --describe`.
54#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
55#[serde(default)]
56pub struct Manifest {
57    /// Unique, lowercase, `a-z0-9-` (the registry key and the install folder).
58    pub id: String,
59    /// Shown in the interface.
60    pub name: String,
61    /// Semver of the plugin.
62    pub version: String,
63    pub description: String,
64    /// The protocol version the plugin speaks ([`API_VERSION`]).
65    pub api: u32,
66    /// Topics ([`topic`]) to receive. Nothing else is sent — or even built.
67    pub subscribe: Vec<String>,
68    /// Extra audio formats ([`format`](mod@format)) for `call.concluded`. The recorder
69    /// encodes each call once for every plugin that asks, when it can.
70    pub audio_formats: Vec<String>,
71    /// JSON Schema of the plugin's settings (an object), for the settings form.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub config: Option<Value>,
74    /// JSON Schema of the settings the plugin takes for each system (an
75    /// object); the form repeats it under each system.
76    #[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// ─── Recorder → plugin ────────────────────────────────────────────────────
98
99// (Messages are read, handled and dropped one at a time: their size doesn't matter.)
100#[allow(clippy::large_enum_variant)]
101#[derive(Clone, Debug, Serialize, Deserialize)]
102#[serde(tag = "type")]
103pub enum HostMessage {
104    /// Always first, once.
105    #[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    /// The recorder is stopping: finish what's queued, then exit. stdin closes
120    /// after this, and the process is killed if it hasn't exited within
121    /// [`Shutdown::grace_s`].
122    #[serde(rename = "shutdown")]
123    Shutdown(Shutdown),
124    /// A message type from a newer recorder.
125    #[serde(other)]
126    Unknown,
127}
128
129#[derive(Clone, Debug, Default, Serialize, Deserialize)]
130#[serde(default)]
131pub struct Hello {
132    /// The recorder's protocol version.
133    pub api: u32,
134    pub host: HostInfo,
135    /// The plugin's settings, as the user entered them (see [`Manifest::config`]);
136    /// each system's, with its overrides, are in [`SystemInfo::settings`].
137    pub config: Value,
138    /// Every system calls can come from, including conventional channels.
139    pub systems: Vec<SystemInfo>,
140    /// Where calls are stored.
141    pub capture_dir: PathBuf,
142    /// A folder of the plugin's own that survives restarts and upgrades (queues, state).
143    pub data_dir: PathBuf,
144    /// The audio formats `call.concluded` will carry this run: always "wav",
145    /// plus those the plugin asked for that the recorder can encode.
146    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
156#[derive(Clone, Debug, Default, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SystemInfo {
159    /// The number events carry for it (`system`) while the recorder runs:
160    /// from 0, the trunked systems then the conventional ones (each a
161    /// system of its own here). It can change between runs: know systems by
162    /// `short_name`.
163    pub index: u16,
164    /// The system's short name: its identity (unique among all the
165    /// recorder's systems), its folder, and what users know it by. Every
166    /// event carries it.
167    pub short_name: String,
168    /// "p25" | "smartnet" | "dmr" | "nxdn" | "conventional"
169    pub kind: String,
170    /// The plugin's settings for this system (see [`Manifest::system_config`]);
171    /// null when the user left them empty.
172    pub config: Value,
173    /// The plugin's own settings ([`Hello::config`]) as they are for this
174    /// system: a system can override some of them (another server, say).
175    /// Null: as in `Hello::config`.
176    #[serde(skip_serializing_if = "Value::is_null")]
177    pub settings: Value,
178}
179
180/// A call as it starts or ends.
181#[derive(Clone, Debug, Default, Serialize, Deserialize)]
182#[serde(default)]
183pub struct CallInfo {
184    /// Unique while the recorder runs (Trunk Recorder's call_num).
185    pub id: u32,
186    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
187    pub system: u16,
188    pub short_name: String,
189    /// The site it is on (its name; a system of one site's is its short
190    /// name; empty for a conventional system's call). A call heard on
191    /// several sites is told once, by the site that had it first.
192    #[serde(skip_serializing_if = "String::is_empty")]
193    pub site: String,
194    pub talkgroup: u32,
195    /// The talkgroup's alpha tag, when known.
196    pub talkgroup_tag: String,
197    pub freq_hz: u64,
198    /// The TDMA slot (0 / 1) of a call on a two-slot channel: P25 Phase 2 or DMR.
199    pub tdma_slot: Option<u8>,
200    pub analog: bool,
201    pub encrypted: bool,
202    /// P25: the cipher an encrypted call's voice named, once heard: the
203    /// algorithm ID (0x84 AES-256, 0xAA ADP, …) and key ID.
204    pub algid: Option<u8>,
205    pub key_id: Option<u16>,
206    /// Some of its encrypted voice was decrypted (the system had the key).
207    pub decrypted: bool,
208    pub emergency: bool,
209    /// Full duplex, packet mode and priority (P25's service options: 1
210    /// lowest, 4 the default, 7 highest; 0 not given), as the grant said.
211    pub duplex: bool,
212    pub packet: bool,
213    pub priority: u8,
214    /// Recorded (false: only followed, or not recorded — see `reason`).
215    pub recording: bool,
216    /// Why it isn't recorded: "encrypted", "unknown_tg", "no_recorder", "no_source", …
217    pub reason: Option<String>,
218    /// Unix seconds.
219    pub start_time: f64,
220    /// Radios heard on the call so far.
221    pub units: Vec<u32>,
222    /// The same radios, in order, with what each one's transmission said of
223    /// itself.
224    pub sources: Vec<CallSource>,
225    /// Talkgroups patched with this one so far, its own included, ascending;
226    /// empty when it isn't patched.
227    pub patched_talkgroups: Vec<u32>,
228}
229
230/// A radio heard on a call, and what its transmission said of itself: P25's
231/// service options (from the grant, link control and Phase 2's voice
232/// channel user messages), or the flags other protocols have.
233#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
234#[serde(default)]
235pub struct CallSource {
236    pub unit: u32,
237    /// Unix seconds it was first heard on the call.
238    pub time: f64,
239    pub emergency: bool,
240    pub encrypted: bool,
241    /// Full duplex (half otherwise).
242    pub duplex: bool,
243    /// Packet mode (circuit otherwise).
244    pub packet: bool,
245    /// 1 lowest, 4 the default, 7 highest; 0 not given.
246    pub priority: u8,
247}
248
249/// A recorded call whose files are on disk.
250#[derive(Clone, Debug, Default, Serialize, Deserialize)]
251#[serde(default)]
252pub struct ConcludedCall {
253    /// The call's key: its files' path relative to the capture folder, without
254    /// an extension (`<shortName>/<Y>/<M>/<D>/<tg>-<start>_<freq>`).
255    pub path: String,
256    /// [`SystemInfo::index`]: for this run only; the system's identity is `call.short_name`.
257    pub system: u16,
258    /// Its call JSON, in Trunk Recorder's format (the `.json` file's contents).
259    pub call: CallRecord,
260    pub files: CallFiles,
261}
262
263#[derive(Clone, Debug, Default, Serialize, Deserialize)]
264#[serde(default)]
265pub struct CallFiles {
266    pub json: PathBuf,
267    /// 16-bit mono WAV, 8 kHz. There whenever `m4a` isn't; with an .m4a it
268    /// may not be (the recorder writes the WAV only when it's kept, or a
269    /// plugin asks for [`format::WAV`]).
270    pub wav: PathBuf,
271    /// When the plugin asked for M4A and the recorder could encode it.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub m4a: Option<PathBuf>,
274}
275
276/// A call's JSON, in Trunk Recorder's format: the `.json` file beside its
277/// audio. The recorder makes one of these for every call and writes it as
278/// it serialises (field order, flags as 0/1, levels to a tenth of a dB);
279/// read back, it accepts Trunk Recorder's own files too (flags as 0/1 or
280/// booleans). Fields neither writes are kept in `extra`. Its default is the
281/// recorder's for what wasn't heard (`-1`, `"off"`, `"digital"`).
282///
283/// Serialise it as JSON with serde_json ([`CallRecord::to_json`]): its
284/// numbers are written through serde_json's raw values, which other serde
285/// formats don't take.
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287#[serde(default)]
288pub struct CallRecord {
289    pub call_num: u64,
290    /// Hz.
291    pub freq: u64,
292    /// How far off its channel the voice carrier came in, Hz (the mean over the call).
293    pub freq_error: i64,
294    /// Levels while it was heard, dB (null: not measured).
295    #[serde(serialize_with = "tenth_or_null")]
296    pub signal: Option<f64>,
297    #[serde(serialize_with = "tenth_or_null")]
298    pub noise: Option<f64>,
299    #[serde(serialize_with = "tenth_or_null")]
300    pub snr: Option<f64>,
301    /// Voice frames decoded without errors, % (null: no digital voice).
302    #[serde(serialize_with = "tenth_or_null")]
303    pub clean_voice_pct: Option<f64>,
304    pub source_num: i64,
305    pub recorder_num: i64,
306    pub tdma_slot: i64,
307    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
308    pub phase2_tdma: bool,
309    /// Unix seconds.
310    pub start_time: i64,
311    pub stop_time: i64,
312    /// Unix ms.
313    pub start_time_ms: i64,
314    pub stop_time_ms: i64,
315    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
316    pub emergency: bool,
317    pub priority: i64,
318    /// Packet mode.
319    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
320    pub mode: bool,
321    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
322    pub duplex: bool,
323    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
324    pub encrypted: bool,
325    /// The cipher its encryption sync named (-1: none heard).
326    pub algid: i64,
327    pub key_id: i64,
328    /// Its encrypted voice was decrypted, at least in part (this recorder's addition).
329    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
330    pub decrypted: bool,
331    /// Seconds of audio.
332    #[serde(serialize_with = "plain")]
333    pub call_length: f64,
334    pub call_length_ms: i64,
335    pub talkgroup: u32,
336    pub talkgroup_tag: String,
337    pub talkgroup_description: String,
338    pub talkgroup_group_tag: String,
339    pub talkgroup_group: String,
340    /// DMR: its colour code (-1: none).
341    pub color_code: i64,
342    /// NXDN: its RAN (-1: none).
343    pub ran: i64,
344    /// An analog call's tone (Trunk Recorder's): "ctcss", "dcs", "search" (identified, not matched) or "off".
345    pub tone_mode: String,
346    pub tone_detected: String,
347    #[serde(serialize_with = "thousandth")]
348    pub tone_confidence: f64,
349    /// "digital" | "digital tdma" | "analog"
350    pub audio_type: String,
351    pub short_name: String,
352    /// The site its copy was kept from (this recorder's addition; a system
353    /// of one site: its short name; none for a conventional system's).
354    #[serde(skip_serializing_if = "String::is_empty")]
355    pub site: String,
356    /// Every talkgroup patched with this one during the call, its own
357    /// included; absent (empty) when it wasn't patched with another.
358    #[serde(skip_serializing_if = "Vec::is_empty")]
359    pub patched_talkgroups: Vec<u32>,
360    #[serde(rename = "freqList")]
361    pub freq_list: Vec<FreqEntry>,
362    /// Voice frame errors over the call, in stretches of its audio.
363    #[serde(rename = "errorList")]
364    pub error_list: Vec<ErrorInterval>,
365    #[serde(rename = "srcList")]
366    pub src_list: Vec<SrcEntry>,
367    #[serde(flatten)]
368    pub extra: Map<String, Value>,
369}
370
371impl Default for CallRecord {
372    fn default() -> Self {
373        CallRecord {
374            call_num: 0,
375            freq: 0,
376            freq_error: 0,
377            signal: None,
378            noise: None,
379            snr: None,
380            clean_voice_pct: None,
381            source_num: 0,
382            recorder_num: 0,
383            tdma_slot: 0,
384            phase2_tdma: false,
385            start_time: 0,
386            stop_time: 0,
387            start_time_ms: 0,
388            stop_time_ms: 0,
389            emergency: false,
390            priority: 0,
391            mode: false,
392            duplex: false,
393            encrypted: false,
394            algid: -1,
395            key_id: -1,
396            decrypted: false,
397            call_length: 0.0,
398            call_length_ms: 0,
399            talkgroup: 0,
400            talkgroup_tag: String::new(),
401            talkgroup_description: String::new(),
402            talkgroup_group_tag: String::new(),
403            talkgroup_group: String::new(),
404            color_code: -1,
405            ran: -1,
406            tone_mode: "off".into(),
407            tone_detected: String::new(),
408            tone_confidence: 0.0,
409            audio_type: "digital".into(),
410            short_name: String::new(),
411            site: String::new(),
412            patched_talkgroups: Vec::new(),
413            freq_list: Vec::new(),
414            error_list: Vec::new(),
415            src_list: Vec::new(),
416            extra: Map::new(),
417        }
418    }
419}
420
421impl CallRecord {
422    /// Voice frames with bit errors, over the call.
423    pub fn error_count(&self) -> u64 {
424        self.freq_list.iter().map(|f| f.error_count).sum()
425    }
426    pub fn spike_count(&self) -> u64 {
427        self.freq_list.iter().map(|f| f.spike_count).sum()
428    }
429    /// As the `.json` file has it.
430    pub fn to_json(&self) -> String {
431        serde_json::to_string(self).unwrap_or_default()
432    }
433}
434
435#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
436#[serde(default)]
437pub struct FreqEntry {
438    pub freq: u64,
439    /// Unix seconds.
440    pub time: i64,
441    /// Seconds into the audio.
442    #[serde(serialize_with = "plain")]
443    pub pos: f64,
444    /// Seconds.
445    #[serde(serialize_with = "plain")]
446    pub len: f64,
447    pub error_count: u64,
448    pub spike_count: u64,
449    #[serde(flatten)]
450    pub extra: Map<String, Value>,
451}
452
453/// A stretch of the call's audio, and its voice frames' errors.
454#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
455#[serde(default)]
456pub struct ErrorInterval {
457    /// Seconds into the audio.
458    #[serde(serialize_with = "plain")]
459    pub pos: f64,
460    /// Seconds.
461    #[serde(serialize_with = "plain")]
462    pub len: f64,
463    pub frames: u64,
464    /// Bit errors corrected.
465    pub error_count: u64,
466    /// Frames too damaged to use.
467    pub bad_frames: u64,
468    pub max_frame_errors: u64,
469}
470
471/// A radio heard on the call.
472#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
473#[serde(default)]
474pub struct SrcEntry {
475    /// Radio ID.
476    pub src: i64,
477    /// Unix seconds.
478    pub time: i64,
479    /// Seconds into the audio.
480    #[serde(serialize_with = "plain")]
481    pub pos: f64,
482    /// What its transmission said of itself (this recorder's: Trunk
483    /// Recorder writes only `emergency` here).
484    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
485    pub emergency: bool,
486    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
487    pub encrypted: bool,
488    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
489    pub duplex: bool,
490    /// Packet mode.
491    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
492    pub mode: bool,
493    /// 1 lowest, 4 the default, 7 highest; 0 not given.
494    pub priority: i64,
495    pub signal_system: String,
496    /// The radio's name from the unit tags file.
497    pub tag: String,
498    /// Its talker alias, heard over the air.
499    pub tag_ota: String,
500    /// Where it said it was (GPS, decimal degrees), when it did.
501    #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
502    pub lat: Option<f64>,
503    #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
504    pub lon: Option<f64>,
505    #[serde(flatten)]
506    pub extra: Map<String, Value>,
507}
508
509// How the call JSON writes its numbers (as Trunk Recorder's files have
510// them): a flag as 0/1, a level to a tenth, a confidence to a thousandth, a
511// position to a millionth, seconds as Rust prints them ("4", not "4.0").
512
513fn number<S: serde::Serializer>(text: String, s: S) -> Result<S::Ok, S::Error> {
514    use serde::ser::Error;
515    serde_json::value::RawValue::from_string(text).map_err(S::Error::custom)?.serialize(s)
516}
517
518fn finite<S: serde::Serializer>(v: f64, text: impl FnOnce(f64) -> String, s: S) -> Result<S::Ok, S::Error> {
519    if v.is_finite() { number(text(v), s) } else { s.serialize_none() }
520}
521
522fn flag_out<S: serde::Serializer>(v: &bool, s: S) -> Result<S::Ok, S::Error> {
523    s.serialize_u8(*v as u8)
524}
525
526fn plain<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
527    finite(*v, |v| format!("{v}"), s)
528}
529
530fn thousandth<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
531    finite(*v, |v| format!("{v:.3}"), s)
532}
533
534fn tenth_or_null<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
535    match v {
536        Some(v) => finite(*v, |v| format!("{v:.1}"), s),
537        None => s.serialize_none(),
538    }
539}
540
541fn millionth<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
542    match v {
543        Some(v) => finite(*v, |v| format!("{v:.6}"), s),
544        None => s.serialize_none(),
545    }
546}
547
548/// Trunk Recorder writes flags as 0/1 and as booleans; take either.
549fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
550    Ok(match Value::deserialize(d)? {
551        Value::Bool(b) => b,
552        Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
553        Value::String(s) => s == "true" || s == "1",
554        _ => false,
555    })
556}
557
558/// Something a radio did on the control channel.
559#[derive(Clone, Debug, Default, Serialize, Deserialize)]
560#[serde(default)]
561pub struct UnitEvent {
562    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
563    pub system: u16,
564    pub short_name: String,
565    /// The site whose control channel told it (its name).
566    #[serde(skip_serializing_if = "String::is_empty")]
567    pub site: String,
568    /// "registration" | "deregistration" | "affiliation" | "acknowledge" |
569    /// "location" | "data_grant" | "answer_request" | "call_alert" |
570    /// "emergency_alarm"
571    pub kind: String,
572    /// Radio ID.
573    pub unit: u32,
574    /// The talkgroup involved (affiliation, location, answer request, call
575    /// alert, emergency alarm).
576    pub talkgroup: Option<u32>,
577    /// The message said it is an emergency (an emergency alarm, a data
578    /// grant's options).
579    pub emergency: bool,
580    /// Unix seconds.
581    pub time: f64,
582}
583
584/// A slice of a recording call's audio: 16-bit mono PCM, little-endian, base64.
585#[derive(Clone, Debug, Default, Serialize, Deserialize)]
586#[serde(default)]
587pub struct AudioChunk {
588    pub call_id: u32,
589    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
590    pub system: u16,
591    pub short_name: String,
592    pub talkgroup: u32,
593    pub sample_rate: u32,
594    pub pcm: String,
595}
596
597impl AudioChunk {
598    pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
599        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
600        AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
601    }
602    /// The samples.
603    pub fn samples(&self) -> Vec<i16> {
604        let b = base64::decode(&self.pcm);
605        b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
606    }
607}
608
609#[derive(Clone, Debug, Default, Serialize, Deserialize)]
610#[serde(default)]
611pub struct Status {
612    /// Unix seconds.
613    pub time: f64,
614    pub systems: Vec<SystemStatus>,
615}
616
617#[derive(Clone, Debug, Default, Serialize, Deserialize)]
618#[serde(default)]
619pub struct SystemStatus {
620    pub index: u16,
621    pub short_name: String,
622    /// One entry per site: this one's name (a system of one site: its short
623    /// name). `site` below is its number, as the control channel says it.
624    pub site_name: String,
625    /// The control channel being decoded (none: searching).
626    pub control_channel_hz: Option<u64>,
627    /// Control channel messages decoded per second.
628    pub decode_rate: f64,
629    pub active_calls: u32,
630    pub recording: u32,
631    /// The system's identity, as far as its control channel has stated it
632    /// (none until it has): P25 NAC, WACN, system ID, RFSS and site; site
633    /// also for SmartNet when its OBT sends one.
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub nac: Option<u32>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub wacn: Option<u32>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub sys_id: Option<u32>,
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub rfss: Option<u32>,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub site: Option<u32>,
644}
645
646#[derive(Clone, Debug, Default, Serialize, Deserialize)]
647#[serde(default)]
648pub struct Shutdown {
649    /// Seconds the plugin has to exit.
650    pub grace_s: f64,
651}
652
653// ─── Plugin → recorder ────────────────────────────────────────────────────
654
655#[derive(Clone, Debug, Serialize, Deserialize)]
656#[serde(tag = "type")]
657pub enum PluginMessage {
658    /// The plugin read its hello and is running.
659    #[serde(rename = "ready")]
660    Ready,
661    #[serde(rename = "log")]
662    Log { level: Level, message: String },
663    /// The plugin's health, shown next to it in the interface.
664    #[serde(rename = "status")]
665    Status {
666        state: State,
667        #[serde(default)]
668        message: String,
669    },
670    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
671    #[serde(rename = "call.result")]
672    CallResult {
673        path: String,
674        outcome: Outcome,
675        #[serde(default, skip_serializing_if = "String::is_empty")]
676        message: String,
677        /// Where the call can be found now (a link to it on the service).
678        #[serde(default, skip_serializing_if = "String::is_empty")]
679        url: String,
680    },
681    /// How the plugin's work is going, for the dashboard: queue, results,
682    /// timing and the services it talks to. Send it when it changes, at most
683    /// every few seconds ([`crate::CallQueue`] does it for you).
684    #[serde(rename = "metrics")]
685    Metrics(Metrics),
686    #[serde(other)]
687    Unknown,
688}
689
690/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
691/// send what you know. Totals are since the plugin started.
692#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
693#[serde(default, rename_all = "camelCase")]
694pub struct Metrics {
695    /// Calls waiting to be worked on (ready, or waiting to retry).
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub queued: Option<u64>,
698    /// Of those, waiting to retry after a failure.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub retrying: Option<u64>,
701    /// Being worked on now.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub in_flight: Option<u64>,
704    /// Tries that failed and will be retried, in total.
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub retries: Option<u64>,
707    /// Bytes sent, in total.
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub bytes_sent: Option<u64>,
710    /// How long one piece of work takes (an upload), ms, smoothed.
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub latency_ms: Option<f64>,
713    /// Unix seconds of the last success and the last failure.
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub last_ok: Option<f64>,
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub last_error: Option<f64>,
718    #[serde(skip_serializing_if = "String::is_empty")]
719    pub last_error_text: String,
720    /// The services it talks to.
721    #[serde(skip_serializing_if = "Vec::is_empty")]
722    pub endpoints: Vec<Endpoint>,
723    /// Anything else worth showing, by name (shown as it is).
724    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
725    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
726}
727
728/// A service a plugin talks to, and how it's answering.
729#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
730#[serde(default, rename_all = "camelCase")]
731pub struct Endpoint {
732    /// What it is ("Broadcastify Calls", a host name).
733    pub name: String,
734    pub state: EndpointState,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub latency_ms: Option<f64>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub last_ok: Option<f64>,
739    #[serde(skip_serializing_if = "String::is_empty")]
740    pub last_error: String,
741}
742
743#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
744#[serde(rename_all = "lowercase")]
745pub enum EndpointState {
746    #[default]
747    Unknown,
748    /// Answering.
749    Up,
750    /// Answering, with failures (retries).
751    Degraded,
752    /// Not answering.
753    Down,
754}
755
756#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
757#[serde(rename_all = "lowercase")]
758pub enum Level {
759    Error,
760    Warn,
761    Info,
762    Debug,
763}
764
765#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
766#[serde(rename_all = "lowercase")]
767pub enum State {
768    /// Working.
769    Ok,
770    /// Working, but something needs attention (a service down, calls queued).
771    Warning,
772    /// Not working (bad settings, nothing it can do).
773    Error,
774}
775
776#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
777#[serde(rename_all = "lowercase")]
778pub enum Outcome {
779    /// Done (uploaded, sent, stored).
780    Ok,
781    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
782    Skipped,
783    /// Gave up on it.
784    Failed,
785}
786
787/// Standard base64 (for [`AudioChunk::pcm`]).
788pub mod base64 {
789    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
790
791    pub fn encode(b: &[u8]) -> String {
792        let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
793        for c in b.chunks(3) {
794            let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
795            for i in 0..4 {
796                if i <= c.len() {
797                    o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
798                } else {
799                    o.push('=');
800                }
801            }
802        }
803        o
804    }
805
806    /// Invalid characters are skipped.
807    pub fn decode(s: &str) -> Vec<u8> {
808        let mut o = Vec::with_capacity(s.len() / 4 * 3);
809        let (mut acc, mut bits) = (0u32, 0);
810        for ch in s.bytes() {
811            let v = match ch {
812                b'A'..=b'Z' => ch - b'A',
813                b'a'..=b'z' => ch - b'a' + 26,
814                b'0'..=b'9' => ch - b'0' + 52,
815                b'+' => 62,
816                b'/' => 63,
817                _ => continue,
818            };
819            acc = acc << 6 | v as u32;
820            bits += 6;
821            if bits >= 8 {
822                bits -= 8;
823                o.push((acc >> bits) as u8);
824            }
825        }
826        o
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    /// A Trunk Recorder file (flags as booleans, a field this crate doesn't
835    /// name) reads; written back, flags are 0/1 as the recorder's own files
836    /// have them, levels to a tenth, seconds as written, and the unknown field kept.
837    #[test]
838    fn a_call_record_round_trips_as_the_recorders_files_are() {
839        let tr = r#"{"freq":851012500,"signal":-31.24,"snr":null,"emergency":true,"encrypted":false,"call_length":4,"talkgroup":101,"short_name":"dcfd",
840            "srcList":[{"src":7,"pos":1.5,"emergency":false,"lat":30.0529}],"freqList":[{"freq":851012500,"len":4.0}],"rec_state_type":"STOPPED"}"#;
841        let r: CallRecord = serde_json::from_str(tr).unwrap();
842        assert!(r.emergency && !r.encrypted && r.signal == Some(-31.24) && r.snr.is_none());
843        let back = r.to_json();
844        assert!(back.contains("\"signal\":-31.2,\"noise\":null,\"snr\":null,"), "{back}");
845        assert!(back.contains("\"emergency\":1,") && back.contains("\"encrypted\":0,"), "{back}");
846        assert!(back.contains("\"call_length\":4,") && back.contains("\"len\":4,"), "{back}");
847        assert!(back.contains("\"pos\":1.5,\"emergency\":0,") && back.contains("\"lat\":30.052900"), "{back}");
848        assert!(back.ends_with("\"rec_state_type\":\"STOPPED\"}"), "{back}");
849        // Read and written again, it's the same.
850        assert_eq!(serde_json::from_str::<CallRecord>(&back).unwrap().to_json(), back);
851    }
852
853    #[test]
854    fn base64_roundtrip() {
855        for n in 0..10 {
856            let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
857            assert_eq!(base64::decode(&base64::encode(&b)), b);
858        }
859        assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
860    }
861
862    #[test]
863    fn unknown_types_and_fields_are_ignored() {
864        let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
865        assert!(matches!(m, HostMessage::Unknown));
866        let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
867        assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
868    }
869
870    #[test]
871    fn call_record_reads_trunk_recorder_json() {
872        let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
873            "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
874            "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
875            "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
876            "color_code":-1,"patched_talkgroups":[101,65001]}"#;
877        let c: CallRecord = serde_json::from_str(j).unwrap();
878        assert!(c.encrypted && !c.emergency);
879        assert_eq!(c.patched_talkgroups, [101, 65001]);
880        assert_eq!((c.error_count(), c.spike_count()), (3, 1));
881        assert_eq!(c.src_list[0].tag_ota, "E1");
882        assert_eq!(c.color_code, -1);
883        // And back.
884        let v = serde_json::to_value(&c).unwrap();
885        assert_eq!(v["color_code"], -1);
886        assert_eq!(v["srcList"][0]["src"], 1234);
887        assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
888        let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
889        assert!(unpatched.get("patched_talkgroups").is_none());
890    }
891
892    #[test]
893    fn system_identity_is_there_once_stated() {
894        // From a recorder that doesn't send it: none of it.
895        let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
896        assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
897        // Nothing stated yet: nothing written.
898        let v = serde_json::to_value(&s).unwrap();
899        assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
900        let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
901        let v = serde_json::to_value(&s).unwrap();
902        assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
903    }
904}