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/// The index calls and events use for the first conventional system; the
157/// next ones count down from it (65534, 65533, …). Each is a system of its
158/// own in [`Hello::systems`], with its own short name and settings.
159pub const CONVENTIONAL: u16 = 65535;
160
161#[derive(Clone, Debug, Default, Serialize, Deserialize)]
162#[serde(default)]
163pub struct SystemInfo {
164    /// The number events carry for it (`system`) while the recorder runs
165    /// (conventional systems: [`CONVENTIONAL`] and down). It can change
166    /// between runs: know systems by `short_name`.
167    pub index: u16,
168    /// The system's short name: its identity (unique among all the
169    /// recorder's systems), its folder, and what users know it by. Every
170    /// event carries it.
171    pub short_name: String,
172    /// "p25" | "smartnet" | "dmr" | "nxdn" | "conventional"
173    pub kind: String,
174    /// The plugin's settings for this system (see [`Manifest::system_config`]);
175    /// null when the user left them empty.
176    pub config: Value,
177    /// The plugin's own settings ([`Hello::config`]) as they are for this
178    /// system: a system can override some of them (another server, say).
179    /// Null: as in `Hello::config`.
180    #[serde(skip_serializing_if = "Value::is_null")]
181    pub settings: Value,
182}
183
184/// A call as it starts or ends.
185#[derive(Clone, Debug, Default, Serialize, Deserialize)]
186#[serde(default)]
187pub struct CallInfo {
188    /// Unique while the recorder runs (Trunk Recorder's call_num).
189    pub id: u32,
190    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
191    pub system: u16,
192    pub short_name: String,
193    /// The site it is on (its name; a system of one site's is its short
194    /// name; empty for a conventional system's call). A call heard on
195    /// several sites is told once, by the site that had it first.
196    #[serde(skip_serializing_if = "String::is_empty")]
197    pub site: String,
198    pub talkgroup: u32,
199    /// The talkgroup's alpha tag, when known.
200    pub talkgroup_tag: String,
201    pub freq_hz: u64,
202    /// The TDMA slot (0 / 1) of a call on a two-slot channel: P25 Phase 2 or DMR.
203    pub tdma_slot: Option<u8>,
204    pub analog: bool,
205    pub encrypted: bool,
206    /// P25: the cipher an encrypted call's voice named, once heard: the
207    /// algorithm ID (0x84 AES-256, 0xAA ADP, …) and key ID.
208    pub algid: Option<u8>,
209    pub key_id: Option<u16>,
210    /// Some of its encrypted voice was decrypted (the system had the key).
211    pub decrypted: bool,
212    pub emergency: bool,
213    /// Full duplex, packet mode and priority (P25's service options: 1
214    /// lowest, 4 the default, 7 highest; 0 not given), as the grant said.
215    pub duplex: bool,
216    pub packet: bool,
217    pub priority: u8,
218    /// Recorded (false: only followed, or not recorded — see `reason`).
219    pub recording: bool,
220    /// Why it isn't recorded: "encrypted", "unknown_tg", "no_recorder", "no_source", …
221    pub reason: Option<String>,
222    /// Unix seconds.
223    pub start_time: f64,
224    /// Radios heard on the call so far.
225    pub units: Vec<u32>,
226    /// The same radios, in order, with what each one's transmission said of
227    /// itself.
228    pub sources: Vec<CallSource>,
229    /// Talkgroups patched with this one so far, its own included, ascending;
230    /// empty when it isn't patched.
231    pub patched_talkgroups: Vec<u32>,
232}
233
234/// A radio heard on a call, and what its transmission said of itself: P25's
235/// service options (from the grant, link control and Phase 2's voice
236/// channel user messages), or the flags other protocols have.
237#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
238#[serde(default)]
239pub struct CallSource {
240    pub unit: u32,
241    /// Unix seconds it was first heard on the call.
242    pub time: f64,
243    pub emergency: bool,
244    pub encrypted: bool,
245    /// Full duplex (half otherwise).
246    pub duplex: bool,
247    /// Packet mode (circuit otherwise).
248    pub packet: bool,
249    /// 1 lowest, 4 the default, 7 highest; 0 not given.
250    pub priority: u8,
251}
252
253/// A recorded call whose files are on disk.
254#[derive(Clone, Debug, Default, Serialize, Deserialize)]
255#[serde(default)]
256pub struct ConcludedCall {
257    /// The call's key: its files' path relative to the capture folder, without
258    /// an extension (`<shortName>/<Y>/<M>/<D>/<tg>-<start>_<freq>`).
259    pub path: String,
260    /// [`SystemInfo::index`]: for this run only; the system's identity is `call.short_name`.
261    pub system: u16,
262    /// Its call JSON, in Trunk Recorder's format (the `.json` file's contents).
263    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    /// 16-bit mono WAV, 8 kHz. There whenever `m4a` isn't; with an .m4a it
272    /// may not be (the recorder writes the WAV only when it's kept, or a
273    /// plugin asks for [`format::WAV`]).
274    pub wav: PathBuf,
275    /// When the plugin asked for M4A and the recorder could encode it.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub m4a: Option<PathBuf>,
278}
279
280/// Trunk Recorder's call JSON. Fields this crate doesn't name are in `extra`.
281#[derive(Clone, Debug, Default, Serialize, Deserialize)]
282#[serde(default)]
283pub struct CallRecord {
284    pub call_num: u64,
285    pub short_name: String,
286    /// The site its copy was kept from (this recorder's addition; a system
287    /// of one site: its short name; none for a conventional system's).
288    #[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    /// Hz.
296    pub freq: u64,
297    /// Unix seconds.
298    pub start_time: i64,
299    pub stop_time: i64,
300    /// Seconds of audio.
301    pub call_length: f64,
302    #[serde(deserialize_with = "flag")]
303    pub emergency: bool,
304    #[serde(deserialize_with = "flag")]
305    pub encrypted: bool,
306    /// Its encrypted voice was decrypted, at least in part (this recorder's
307    /// addition, beside `algid` and `key_id`, which are in `extra`).
308    #[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    /// "digital" | "digital tdma" | "analog"
315    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    /// Every talkgroup patched with this one during the call, its own
321    /// included; absent (empty) when it wasn't patched with another.
322    #[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    /// Voice frames with bit errors, over the call.
330    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    /// Unix seconds.
343    pub time: i64,
344    /// Seconds into the audio.
345    pub pos: f64,
346    /// Seconds.
347    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/// A radio heard on the call.
355#[derive(Clone, Debug, Default, Serialize, Deserialize)]
356#[serde(default)]
357pub struct SrcEntry {
358    /// Radio ID.
359    pub src: i64,
360    /// Unix seconds.
361    pub time: i64,
362    /// Seconds into the audio.
363    pub pos: f64,
364    /// What its transmission said of itself (this recorder's: Trunk
365    /// Recorder writes only `emergency` here).
366    #[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    /// Packet mode.
373    #[serde(deserialize_with = "flag")]
374    pub mode: bool,
375    /// 1 lowest, 4 the default, 7 highest; 0 not given.
376    pub priority: i64,
377    pub signal_system: String,
378    /// The radio's name from the unit tags file.
379    pub tag: String,
380    /// Its talker alias, heard over the air.
381    pub tag_ota: String,
382    /// Where it said it was (GPS, decimal degrees), when it did.
383    #[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
391/// Trunk Recorder writes flags as 0/1 and as booleans; take either.
392fn 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/// Something a radio did on the control channel.
402#[derive(Clone, Debug, Default, Serialize, Deserialize)]
403#[serde(default)]
404pub struct UnitEvent {
405    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
406    pub system: u16,
407    pub short_name: String,
408    /// The site whose control channel told it (its name).
409    #[serde(skip_serializing_if = "String::is_empty")]
410    pub site: String,
411    /// "registration" | "deregistration" | "affiliation" | "acknowledge" |
412    /// "location" | "data_grant" | "answer_request" | "call_alert" |
413    /// "emergency_alarm"
414    pub kind: String,
415    /// Radio ID.
416    pub unit: u32,
417    /// The talkgroup involved (affiliation, location, answer request, call
418    /// alert, emergency alarm).
419    pub talkgroup: Option<u32>,
420    /// The message said it is an emergency (an emergency alarm, a data
421    /// grant's options).
422    pub emergency: bool,
423    /// Unix seconds.
424    pub time: f64,
425}
426
427/// A slice of a recording call's audio: 16-bit mono PCM, little-endian, base64.
428#[derive(Clone, Debug, Default, Serialize, Deserialize)]
429#[serde(default)]
430pub struct AudioChunk {
431    pub call_id: u32,
432    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
433    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    /// The samples.
446    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    /// Unix seconds.
456    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    /// One entry per site: this one's name (a system of one site: its short
466    /// name). `site` below is its number, as the control channel says it.
467    pub site_name: String,
468    /// The control channel being decoded (none: searching).
469    pub control_channel_hz: Option<u64>,
470    /// Control channel messages decoded per second.
471    pub decode_rate: f64,
472    pub active_calls: u32,
473    pub recording: u32,
474    /// The system's identity, as far as its control channel has stated it
475    /// (none until it has): P25 NAC, WACN, system ID, RFSS and site; site
476    /// also for SmartNet when its OBT sends one.
477    #[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    /// Seconds the plugin has to exit.
493    pub grace_s: f64,
494}
495
496// ─── Plugin → recorder ────────────────────────────────────────────────────
497
498#[derive(Clone, Debug, Serialize, Deserialize)]
499#[serde(tag = "type")]
500pub enum PluginMessage {
501    /// The plugin read its hello and is running.
502    #[serde(rename = "ready")]
503    Ready,
504    #[serde(rename = "log")]
505    Log { level: Level, message: String },
506    /// The plugin's health, shown next to it in the interface.
507    #[serde(rename = "status")]
508    Status {
509        state: State,
510        #[serde(default)]
511        message: String,
512    },
513    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
514    #[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        /// Where the call can be found now (a link to it on the service).
521        #[serde(default, skip_serializing_if = "String::is_empty")]
522        url: String,
523    },
524    /// How the plugin's work is going, for the dashboard: queue, results,
525    /// timing and the services it talks to. Send it when it changes, at most
526    /// every few seconds ([`crate::CallQueue`] does it for you).
527    #[serde(rename = "metrics")]
528    Metrics(Metrics),
529    #[serde(other)]
530    Unknown,
531}
532
533/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
534/// send what you know. Totals are since the plugin started.
535#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
536#[serde(default, rename_all = "camelCase")]
537pub struct Metrics {
538    /// Calls waiting to be worked on (ready, or waiting to retry).
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub queued: Option<u64>,
541    /// Of those, waiting to retry after a failure.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub retrying: Option<u64>,
544    /// Being worked on now.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub in_flight: Option<u64>,
547    /// Tries that failed and will be retried, in total.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub retries: Option<u64>,
550    /// Bytes sent, in total.
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub bytes_sent: Option<u64>,
553    /// How long one piece of work takes (an upload), ms, smoothed.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub latency_ms: Option<f64>,
556    /// Unix seconds of the last success and the last failure.
557    #[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    /// The services it talks to.
564    #[serde(skip_serializing_if = "Vec::is_empty")]
565    pub endpoints: Vec<Endpoint>,
566    /// Anything else worth showing, by name (shown as it is).
567    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
568    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
569}
570
571/// A service a plugin talks to, and how it's answering.
572#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
573#[serde(default, rename_all = "camelCase")]
574pub struct Endpoint {
575    /// What it is ("Broadcastify Calls", a host name).
576    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    /// Answering.
592    Up,
593    /// Answering, with failures (retries).
594    Degraded,
595    /// Not answering.
596    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    /// Working.
612    Ok,
613    /// Working, but something needs attention (a service down, calls queued).
614    Warning,
615    /// Not working (bad settings, nothing it can do).
616    Error,
617}
618
619#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
620#[serde(rename_all = "lowercase")]
621pub enum Outcome {
622    /// Done (uploaded, sent, stored).
623    Ok,
624    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
625    Skipped,
626    /// Gave up on it.
627    Failed,
628}
629
630/// Standard base64 (for [`AudioChunk::pcm`]).
631pub 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    /// Invalid characters are skipped.
650    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        // And back, with the unknown field kept.
708        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        // From a recorder that doesn't send it: none of it.
719        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        // Nothing stated yet: nothing written.
722        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}