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    /// A call's tones made a pattern ([`super::TonePatternEvent`]): every page,
40    /// alert tone, DTMF string and Morse ID heard, named by the system's signals or not.
41    pub const TONE_PATTERN: &str = "tone_pattern";
42
43    pub const ALL: &[&str] = &[CALL_START, CALL_END, CALL_CONCLUDED, UNIT, AUDIO, STATUS, TONE_PATTERN];
44}
45
46/// Audio formats a plugin can ask for ([`Manifest::audio_formats`]). A plugin
47/// that asks for none gets the WAV.
48pub mod format {
49    /// AAC in an MP4 container (OpenMHz, Broadcastify Calls).
50    pub const M4A: &str = "m4a";
51    /// The WAV file as well as the M4A: with an .m4a made, the WAV is
52    /// otherwise written only when it's kept.
53    pub const WAV: &str = "wav";
54}
55
56/// Who a plugin is and what it wants — printed by `plugin --describe`.
57#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
58#[serde(default)]
59pub struct Manifest {
60    /// Unique, lowercase, `a-z0-9-` (the registry key and the install folder).
61    pub id: String,
62    /// Shown in the interface.
63    pub name: String,
64    /// Semver of the plugin.
65    pub version: String,
66    pub description: String,
67    /// The protocol version the plugin speaks ([`API_VERSION`]).
68    pub api: u32,
69    /// Topics ([`topic`]) to receive. Nothing else is sent — or even built.
70    pub subscribe: Vec<String>,
71    /// Extra audio formats ([`format`](mod@format)) for `call.concluded`. The recorder
72    /// encodes each call once for every plugin that asks, when it can.
73    pub audio_formats: Vec<String>,
74    /// JSON Schema of the plugin's settings (an object), for the settings form.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub config: Option<Value>,
77    /// JSON Schema of the settings the plugin takes for each system (an
78    /// object); the form repeats it under each system.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub system_config: Option<Value>,
81    #[serde(skip_serializing_if = "String::is_empty")]
82    pub homepage: String,
83    #[serde(skip_serializing_if = "String::is_empty")]
84    pub repository: String,
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub authors: Vec<String>,
87    #[serde(skip_serializing_if = "String::is_empty")]
88    pub license: String,
89    /// A page of its own in the interface, in the navigation, for settings
90    /// too many for its card (rules, destinations, …). None: its card in
91    /// Setup is enough.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub page: Option<Page>,
94    /// Buttons it adds elsewhere in the interface, each adding an item to
95    /// its settings from what it's on ([`Contribution`]).
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    pub contribute: Vec<Contribution>,
98    /// It takes new settings while it runs ([`HostMessage::Settings`]):
99    /// changing its own settings doesn't restart it, or anything else.
100    #[serde(skip_serializing_if = "std::ops::Not::not")]
101    pub live_settings: bool,
102}
103
104/// A plugin's own page ([`Manifest::page`]).
105#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
106#[serde(default)]
107pub struct Page {
108    /// Its name in the navigation ("Notifications"); empty: the plugin's name.
109    #[serde(skip_serializing_if = "String::is_empty")]
110    pub title: String,
111}
112
113/// A button a plugin adds where the interface shows something it can act on
114/// ([`Manifest::contribute`]). Pressed, it adds `item` to the plugin's
115/// settings array `append`, with `{placeholders}` in its strings filled in
116/// from what it was pressed on, and opens the plugin's page (or card) there.
117///
118/// On `signal` (each of a system's signals, in Setup): `{system}`
119/// (its short name), `{signal}` (`system:ID`), `{id}` and `{name}` (its
120/// name, else its id). A string that is just one placeholder inside an array
121/// (`["{signal}"]`) becomes that value.
122#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
123#[serde(default)]
124pub struct Contribution {
125    /// Where the button goes: `signal`.
126    pub on: String,
127    /// What it says ("Notify").
128    pub label: String,
129    /// A tooltip.
130    #[serde(skip_serializing_if = "String::is_empty")]
131    pub hint: String,
132    /// The settings array (a top-level key of the plugin's settings) it adds to.
133    pub append: String,
134    /// The item it adds.
135    pub item: Value,
136}
137
138impl Manifest {
139    pub fn subscribes(&self, topic: &str) -> bool {
140        self.subscribe.iter().any(|t| t == topic)
141    }
142    pub fn wants_format(&self, format: &str) -> bool {
143        self.audio_formats.iter().any(|f| f == format)
144    }
145}
146
147// ─── Recorder → plugin ────────────────────────────────────────────────────
148
149// (Messages are read, handled and dropped one at a time: their size doesn't matter.)
150#[allow(clippy::large_enum_variant)]
151#[derive(Clone, Debug, Serialize, Deserialize)]
152#[serde(tag = "type")]
153pub enum HostMessage {
154    /// Always first, once.
155    #[serde(rename = "hello")]
156    Hello(Hello),
157    #[serde(rename = "call.start")]
158    CallStart(CallInfo),
159    #[serde(rename = "call.end")]
160    CallEnd(CallInfo),
161    #[serde(rename = "call.concluded")]
162    CallConcluded(ConcludedCall),
163    #[serde(rename = "unit")]
164    Unit(UnitEvent),
165    #[serde(rename = "audio")]
166    Audio(AudioChunk),
167    #[serde(rename = "status")]
168    Status(Status),
169    #[serde(rename = "tone_pattern")]
170    TonePattern(TonePatternEvent),
171    /// Someone pressed one of the plugin's buttons (`x-actions` in its
172    /// settings schema): answer with [`PluginMessage::ActionResult`]. Only
173    /// a plugin that has actions is sent one.
174    #[serde(rename = "action")]
175    Action(Action),
176    /// Its settings changed: the hello it would start with now. Only a
177    /// plugin with [`Manifest::live_settings`] is sent one; others restart.
178    #[serde(rename = "settings")]
179    Settings(Hello),
180    /// The recorder is stopping: finish what's queued, then exit. stdin closes
181    /// after this, and the process is killed if it hasn't exited within
182    /// [`Shutdown::grace_s`].
183    #[serde(rename = "shutdown")]
184    Shutdown(Shutdown),
185    /// A message type from a newer recorder.
186    #[serde(other)]
187    Unknown,
188}
189
190#[derive(Clone, Debug, Default, Serialize, Deserialize)]
191#[serde(default)]
192pub struct Hello {
193    /// The recorder's protocol version.
194    pub api: u32,
195    pub host: HostInfo,
196    /// The plugin's settings, as the user entered them (see [`Manifest::config`]);
197    /// each system's, with its overrides, are in [`SystemInfo::settings`].
198    pub config: Value,
199    /// Every system calls can come from, including conventional channels.
200    pub systems: Vec<SystemInfo>,
201    /// Where calls are stored.
202    pub capture_dir: PathBuf,
203    /// A folder of the plugin's own that survives restarts and upgrades (queues, state).
204    pub data_dir: PathBuf,
205    /// The audio formats `call.concluded` will carry this run: always "wav",
206    /// plus those the plugin asked for that the recorder can encode.
207    pub audio_formats: Vec<String>,
208}
209
210#[derive(Clone, Debug, Default, Serialize, Deserialize)]
211#[serde(default)]
212pub struct HostInfo {
213    pub name: String,
214    pub version: String,
215}
216
217#[derive(Clone, Debug, Default, Serialize, Deserialize)]
218#[serde(default)]
219pub struct SystemInfo {
220    /// The number events carry for it (`system`) while the recorder runs:
221    /// from 0, the trunked systems then the conventional ones (each a
222    /// system of its own here). It can change between runs: know systems by
223    /// `short_name`.
224    pub index: u16,
225    /// The system's short name: its identity (unique among all the
226    /// recorder's systems), its folder, and what users know it by. Every
227    /// event carries it.
228    pub short_name: String,
229    /// "p25" | "smartnet" | "dmr" | "nxdn" | "edacs" | "conventional"
230    pub kind: String,
231    /// What people call it ("County Public Safety"); empty when the user gave no name.
232    #[serde(skip_serializing_if = "String::is_empty")]
233    pub name: String,
234    /// Its FCC licenses by frequency (Hz, as a string): who is licensed on
235    /// each of its channels, from the user, the air (Motorola's base station
236    /// ID) or a license lookup. Optional, so often empty.
237    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
238    pub fcc: std::collections::BTreeMap<String, FccLicense>,
239    /// The call sign that names it, of those: its control channel's, else
240    /// the one on the most frequencies ("WPXT459"); empty when none.
241    #[serde(skip_serializing_if = "String::is_empty")]
242    pub fcc_call_sign: String,
243    /// Who holds that license, as the FCC has it ("DISTRICT OF COLUMBIA"); empty when not known.
244    #[serde(skip_serializing_if = "String::is_empty")]
245    pub fcc_name: String,
246    /// Its sites (trunked systems; none for conventional), by the name calls
247    /// and unit events carry as `site`.
248    #[serde(skip_serializing_if = "Vec::is_empty")]
249    pub sites: Vec<SiteInfo>,
250    /// The plugin's settings for this system (see [`Manifest::system_config`]);
251    /// null when the user left them empty.
252    pub config: Value,
253    /// The plugin's own settings ([`Hello::config`]) as they are for this
254    /// system: a system can override some of them (another server, say).
255    /// Null: as in `Hello::config`.
256    #[serde(skip_serializing_if = "Value::is_null")]
257    pub settings: Value,
258}
259
260/// One site of a [`SystemInfo`].
261#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
262#[serde(default)]
263pub struct SiteInfo {
264    /// What calls and unit events carry as `site`.
265    pub name: String,
266}
267
268/// Who is licensed on one of a system's frequencies ([`SystemInfo::fcc`]).
269#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270#[serde(default)]
271pub struct FccLicense {
272    pub call_sign: String,
273    /// As the FCC has it; empty when not looked up.
274    #[serde(skip_serializing_if = "String::is_empty")]
275    pub name: String,
276}
277
278/// A call as it starts or ends.
279#[derive(Clone, Debug, Default, Serialize, Deserialize)]
280#[serde(default)]
281pub struct CallInfo {
282    /// Unique while the recorder runs (Trunk Recorder's call_num).
283    pub id: u32,
284    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
285    pub system: u16,
286    pub short_name: String,
287    /// The site it is on (its name; a system of one site's is its short
288    /// name; empty for a conventional system's call). A call heard on
289    /// several sites is told once, by the site that had it first.
290    #[serde(skip_serializing_if = "String::is_empty")]
291    pub site: String,
292    pub talkgroup: u32,
293    /// Conventional P25 / DMR / NXDN: the talkgroup the air named (this
294    /// recorder's addition, like `site`). `talkgroup` is what the call is
295    /// filed under, which a channel row may set to another number; none
296    /// for analog, trunked calls, or a call whose air named none.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub talkgroup_ota: Option<u32>,
299    /// The talkgroup's alpha tag, when known.
300    pub talkgroup_tag: String,
301    pub freq_hz: u64,
302    /// The TDMA slot (0 / 1) of a call on a two-slot channel: P25 Phase 2 or DMR.
303    pub tdma_slot: Option<u8>,
304    pub analog: bool,
305    pub encrypted: bool,
306    /// P25: the cipher an encrypted call's voice named, once heard: the
307    /// algorithm ID (0x84 AES-256, 0xAA ADP, …) and key ID.
308    pub algid: Option<u8>,
309    pub key_id: Option<u16>,
310    /// Some of its encrypted voice was decrypted (the system had the key).
311    pub decrypted: bool,
312    pub emergency: bool,
313    /// Full duplex, packet mode and priority (P25's service options: 1
314    /// lowest, 4 the default, 7 highest; 0 not given), as the grant said.
315    pub duplex: bool,
316    pub packet: bool,
317    pub priority: u8,
318    /// Recorded (false: only followed, or not recorded — see `reason`).
319    pub recording: bool,
320    /// Why it isn't recorded: "encrypted", "unknown_tg", "no_recorder", "no_source", …
321    pub reason: Option<String>,
322    /// Unix seconds.
323    pub start_time: f64,
324    /// Radios heard on the call so far.
325    pub units: Vec<u32>,
326    /// The same radios, in order, with what each one's transmission said of
327    /// itself.
328    pub sources: Vec<CallSource>,
329    /// Talkgroups patched with this one so far, its own included, ascending;
330    /// empty when it isn't patched.
331    pub patched_talkgroups: Vec<u32>,
332}
333
334/// A radio heard on a call, and what its transmission said of itself: P25's
335/// service options (from the grant, link control and Phase 2's voice
336/// channel user messages), or the flags other protocols have.
337#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
338#[serde(default)]
339pub struct CallSource {
340    pub unit: u32,
341    /// Its name, as the call JSON's `srcList` `tag` will have it (the
342    /// system's units file, or its talker alias). Empty: none known yet.
343    #[serde(skip_serializing_if = "String::is_empty")]
344    pub tag: String,
345    /// Unix seconds it was first heard on the call.
346    pub time: f64,
347    pub emergency: bool,
348    pub encrypted: bool,
349    /// Full duplex (half otherwise).
350    pub duplex: bool,
351    /// Packet mode (circuit otherwise).
352    pub packet: bool,
353    /// 1 lowest, 4 the default, 7 highest; 0 not given.
354    pub priority: u8,
355}
356
357/// A recorded call whose files are on disk.
358#[derive(Clone, Debug, Default, Serialize, Deserialize)]
359#[serde(default)]
360pub struct ConcludedCall {
361    /// The call's key: its files' path relative to the capture folder, without
362    /// an extension (`<shortName>/<Y>/<M>/<D>/<tg>-<start>_<freq>`).
363    pub path: String,
364    /// [`SystemInfo::index`]: for this run only; the system's identity is `call.short_name`.
365    pub system: u16,
366    /// Its call JSON, in Trunk Recorder's format (the `.json` file's contents).
367    pub call: CallRecord,
368    pub files: CallFiles,
369}
370
371#[derive(Clone, Debug, Default, Serialize, Deserialize)]
372#[serde(default)]
373pub struct CallFiles {
374    pub json: PathBuf,
375    /// 16-bit mono WAV, 8 kHz. There whenever `m4a` isn't; with an .m4a it
376    /// may not be (the recorder writes the WAV only when it's kept, or a
377    /// plugin asks for [`format::WAV`]).
378    pub wav: PathBuf,
379    /// When the plugin asked for M4A and the recorder could encode it.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub m4a: Option<PathBuf>,
382}
383
384/// A call's JSON, in Trunk Recorder's format: the `.json` file beside its
385/// audio. The recorder makes one of these for every call and writes it as
386/// it serialises (field order, flags as 0/1, levels to a tenth of a dB);
387/// read back, it accepts Trunk Recorder's own files too (flags as 0/1 or
388/// booleans). Fields neither writes are kept in `extra`. Its default is the
389/// recorder's for what wasn't heard (`-1`, `"off"`, `"digital"`).
390///
391/// Serialise it as JSON with serde_json ([`CallRecord::to_json`]): its
392/// numbers are written through serde_json's raw values, which other serde
393/// formats don't take.
394#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
395#[serde(default)]
396pub struct CallRecord {
397    pub call_num: u64,
398    /// The same on every copy of one transmission (a system's sites, each
399    /// recording it): `<system>-<talkgroup>-<radio>-<start ms>`, the radio
400    /// the first known to talk (0: none), the start the first copy's. With
401    /// conversation mode on, a call's first transmission names it.
402    #[serde(skip_serializing_if = "String::is_empty")]
403    pub transmission_id: String,
404    /// Hz.
405    pub freq: u64,
406    /// How far off its channel the voice carrier came in, Hz (the mean over the call).
407    pub freq_error: i64,
408    /// Levels while it was heard, dB (null: not measured).
409    #[serde(serialize_with = "tenth_or_null")]
410    pub signal: Option<f64>,
411    #[serde(serialize_with = "tenth_or_null")]
412    pub noise: Option<f64>,
413    #[serde(serialize_with = "tenth_or_null")]
414    pub snr: Option<f64>,
415    /// Voice frames decoded without errors, % (null: no digital voice).
416    #[serde(serialize_with = "tenth_or_null")]
417    pub clean_voice_pct: Option<f64>,
418    pub source_num: i64,
419    pub recorder_num: i64,
420    pub tdma_slot: i64,
421    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
422    pub phase2_tdma: bool,
423    /// Unix seconds.
424    pub start_time: i64,
425    pub stop_time: i64,
426    /// Unix ms.
427    pub start_time_ms: i64,
428    pub stop_time_ms: i64,
429    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
430    pub emergency: bool,
431    pub priority: i64,
432    /// Packet mode.
433    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
434    pub mode: bool,
435    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
436    pub duplex: bool,
437    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
438    pub encrypted: bool,
439    /// The cipher its encryption sync named (-1: none heard).
440    pub algid: i64,
441    pub key_id: i64,
442    /// Its encrypted voice was decrypted, at least in part (this recorder's addition).
443    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
444    pub decrypted: bool,
445    /// Its times may be off: when it ended, the recorder's clock was measured
446    /// a quarter second or more off, or the operating system said it wasn't
447    /// synchronized (this recorder's addition; absent when the clock was fine).
448    #[serde(skip_serializing_if = "std::ops::Not::not")]
449    pub clock_off: bool,
450    /// Seconds of audio.
451    #[serde(serialize_with = "plain")]
452    pub call_length: f64,
453    pub call_length_ms: i64,
454    pub talkgroup: u32,
455    /// Conventional P25 / DMR / NXDN: the talkgroup the air named (this
456    /// recorder's addition, like `site`). `talkgroup` is what the call is
457    /// filed under, which a channel row may set to another number; none
458    /// for analog, trunked calls, or a call whose air named none.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub talkgroup_ota: Option<u32>,
461    pub talkgroup_tag: String,
462    pub talkgroup_description: String,
463    pub talkgroup_group_tag: String,
464    pub talkgroup_group: String,
465    /// P25: the NAC its voice carried on a conventional channel, its site's
466    /// on a trunked system, in hex as radios and channel files write it
467    /// (`"293"`; empty: none; this recorder's addition).
468    pub nac: String,
469    /// DMR: its colour code (-1: none).
470    pub color_code: i64,
471    /// NXDN: its RAN (-1: none).
472    pub ran: i64,
473    /// An analog call's tone (Trunk Recorder's): "ctcss", "dcs", "search" (identified, not matched) or "off".
474    pub tone_mode: String,
475    pub tone_detected: String,
476    #[serde(serialize_with = "thousandth")]
477    pub tone_confidence: f64,
478    /// "digital" | "digital tdma" | "analog"
479    pub audio_type: String,
480    /// The kind of system it came on, as [`SystemInfo::kind`] has it: "p25" |
481    /// "smartnet" | "dmr" | "nxdn" | "edacs" | "conventional" (this recorder's addition).
482    #[serde(skip_serializing_if = "String::is_empty")]
483    pub system_type: String,
484    /// What its voice was: "p25" | "p25_phase2" | "dmr" | "nxdn48" | "nxdn96"
485    /// | "provoice" | "fm" | "am" — a conventional channel's `mode`, or a trunked grant's
486    /// (this recorder's addition).
487    #[serde(skip_serializing_if = "String::is_empty")]
488    pub voice_mode: String,
489    pub short_name: String,
490    /// The site its copy was kept from (this recorder's addition; a system
491    /// of one site: its short name; none for a conventional system's).
492    #[serde(skip_serializing_if = "String::is_empty")]
493    pub site: String,
494    /// Every talkgroup patched with this one during the call, its own
495    /// included; absent (empty) when it wasn't patched with another.
496    #[serde(skip_serializing_if = "Vec::is_empty")]
497    pub patched_talkgroups: Vec<u32>,
498    #[serde(rename = "freqList")]
499    pub freq_list: Vec<FreqEntry>,
500    /// Voice frame errors over the call, in stretches of its audio.
501    #[serde(rename = "errorList")]
502    pub error_list: Vec<ErrorInterval>,
503    #[serde(rename = "srcList")]
504    pub src_list: Vec<SrcEntry>,
505    /// In-band tones sent in its voice (DTMF, KNOX, call progress, single
506    /// tones), in order. This recorder's: Trunk Recorder has none.
507    #[serde(skip_serializing_if = "Vec::is_empty")]
508    pub tones: Vec<ToneEntry>,
509    /// What its tones make up: pages, alerts, digit strings, in order.
510    #[serde(skip_serializing_if = "Vec::is_empty")]
511    pub tone_patterns: Vec<TonePattern>,
512    /// The system's signals it carried (their ids), in the order first heard.
513    #[serde(skip_serializing_if = "Vec::is_empty")]
514    pub signals: Vec<String>,
515    #[serde(flatten)]
516    pub extra: Map<String, Value>,
517}
518
519impl Default for CallRecord {
520    fn default() -> Self {
521        CallRecord {
522            call_num: 0,
523            transmission_id: String::new(),
524            freq: 0,
525            freq_error: 0,
526            signal: None,
527            noise: None,
528            snr: None,
529            clean_voice_pct: None,
530            source_num: 0,
531            recorder_num: 0,
532            tdma_slot: 0,
533            phase2_tdma: false,
534            start_time: 0,
535            stop_time: 0,
536            start_time_ms: 0,
537            stop_time_ms: 0,
538            emergency: false,
539            priority: 0,
540            mode: false,
541            duplex: false,
542            encrypted: false,
543            algid: -1,
544            key_id: -1,
545            decrypted: false,
546            clock_off: false,
547            call_length: 0.0,
548            call_length_ms: 0,
549            talkgroup: 0,
550            talkgroup_ota: None,
551            talkgroup_tag: String::new(),
552            talkgroup_description: String::new(),
553            talkgroup_group_tag: String::new(),
554            talkgroup_group: String::new(),
555            nac: String::new(),
556            color_code: -1,
557            ran: -1,
558            tone_mode: "off".into(),
559            tone_detected: String::new(),
560            tone_confidence: 0.0,
561            audio_type: "digital".into(),
562            system_type: String::new(),
563            voice_mode: String::new(),
564            short_name: String::new(),
565            site: String::new(),
566            patched_talkgroups: Vec::new(),
567            freq_list: Vec::new(),
568            error_list: Vec::new(),
569            src_list: Vec::new(),
570            tones: Vec::new(),
571            tone_patterns: Vec::new(),
572            signals: Vec::new(),
573            extra: Map::new(),
574        }
575    }
576}
577
578impl CallRecord {
579    /// Voice frames with bit errors, over the call.
580    pub fn error_count(&self) -> u64 {
581        self.freq_list.iter().map(|f| f.error_count).sum()
582    }
583    pub fn spike_count(&self) -> u64 {
584        self.freq_list.iter().map(|f| f.spike_count).sum()
585    }
586    /// As the `.json` file has it.
587    pub fn to_json(&self) -> String {
588        serde_json::to_string(self).unwrap_or_default()
589    }
590}
591
592#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
593#[serde(default)]
594pub struct FreqEntry {
595    pub freq: u64,
596    /// Unix seconds.
597    pub time: i64,
598    /// Seconds into the audio.
599    #[serde(serialize_with = "plain")]
600    pub pos: f64,
601    /// Seconds.
602    #[serde(serialize_with = "plain")]
603    pub len: f64,
604    pub error_count: u64,
605    pub spike_count: u64,
606    #[serde(flatten)]
607    pub extra: Map<String, Value>,
608}
609
610/// A stretch of the call's audio, and its voice frames' errors.
611#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
612#[serde(default)]
613pub struct ErrorInterval {
614    /// Seconds into the audio.
615    #[serde(serialize_with = "plain")]
616    pub pos: f64,
617    /// Seconds.
618    #[serde(serialize_with = "plain")]
619    pub len: f64,
620    pub frames: u64,
621    /// Bit errors corrected.
622    pub error_count: u64,
623    /// Frames too damaged to use.
624    pub bad_frames: u64,
625    pub max_frame_errors: u64,
626}
627
628/// A radio heard on the call.
629#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
630#[serde(default)]
631pub struct SrcEntry {
632    /// Radio ID.
633    pub src: i64,
634    /// Unix seconds.
635    pub time: i64,
636    /// Seconds into the audio.
637    #[serde(serialize_with = "plain")]
638    pub pos: f64,
639    /// What its transmission said of itself (this recorder's: Trunk
640    /// Recorder writes only `emergency` here).
641    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
642    pub emergency: bool,
643    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
644    pub encrypted: bool,
645    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
646    pub duplex: bool,
647    /// Packet mode.
648    #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
649    pub mode: bool,
650    /// 1 lowest, 4 the default, 7 highest; 0 not given.
651    pub priority: i64,
652    pub signal_system: String,
653    /// The radio's name from the unit tags file.
654    pub tag: String,
655    /// Its talker alias, heard over the air.
656    pub tag_ota: String,
657    /// Where it said it was (GPS, decimal degrees), when it did.
658    #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
659    pub lat: Option<f64>,
660    #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
661    pub lon: Option<f64>,
662    #[serde(flatten)]
663    pub extra: Map<String, Value>,
664}
665
666/// An in-band tone sent in the call's voice.
667#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
668#[serde(default)]
669pub struct ToneEntry {
670    /// `dtmf`, `knox`, `call_progress` or `tone` (a single tone).
671    #[serde(rename = "type")]
672    pub kind: String,
673    /// The digit (`0`–`9`, `A`–`D`, `*`, `#`); call progress, its two
674    /// frequencies (`350+440`); a single tone, its frequency in Hz.
675    pub value: String,
676    /// Seconds into the audio.
677    #[serde(serialize_with = "plain")]
678    pub pos: f64,
679    /// Seconds.
680    #[serde(serialize_with = "plain")]
681    pub len: f64,
682    /// The radio talking when it was sent (0: not known).
683    pub src: i64,
684    /// `frame`: named by the vocoder's tone frames (half-rate digital
685    /// voice: P25 Phase 2, DMR, NXDN); `audio`: found in the audio (P25
686    /// Phase 1, analog, half-rate voice sent without tone frames).
687    pub origin: String,
688    /// Tone frames: the level they named, dBm0.
689    #[serde(skip_serializing_if = "Option::is_none", serialize_with = "tenth_or_null")]
690    pub level: Option<f64>,
691    #[serde(flatten)]
692    pub extra: Map<String, Value>,
693}
694
695/// A signal the call's tones make up.
696#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
697#[serde(default)]
698pub struct TonePattern {
699    /// `two_tone` (a page: tone A then B), `long` (one tone, a second or
700    /// more), `hi_low` (a warble), `pulsed`, `digits` (DTMF / KNOX), `morse`
701    /// (a CW ID), `sequence` (a run of short tones: selective calling, a
702    /// chirp) or `beep`.
703    pub pattern: String,
704    /// Its tones, Hz: two-tone A then B; hi-low its two, lower first; a
705    /// sequence each in turn; otherwise its one.
706    pub tones: Vec<f64>,
707    /// Seconds: two-tone A and B; hi-low each tone's; pulsed on then off;
708    /// Morse its dot; a sequence each in turn; otherwise its one.
709    pub lengths: Vec<f64>,
710    /// Digits: the string, and `knox` when they're KNOX tones (DTMF
711    /// otherwise); Morse: the text.
712    #[serde(skip_serializing_if = "String::is_empty")]
713    pub digits: String,
714    #[serde(skip_serializing_if = "std::ops::Not::not")]
715    pub knox: bool,
716    /// Hi-low: its tones; pulsed: its pulses.
717    #[serde(skip_serializing_if = "is_zero")]
718    pub cycles: u32,
719    /// A two-tone page's format, by its timing (`Quick Call II`, …).
720    #[serde(skip_serializing_if = "String::is_empty")]
721    pub format: String,
722    /// Seconds into the audio.
723    #[serde(serialize_with = "plain")]
724    pub pos: f64,
725    /// Seconds.
726    #[serde(serialize_with = "plain")]
727    pub len: f64,
728    /// The radio talking when it began (0: not known).
729    pub src: i64,
730    /// `frame` or `audio`, as its tones' ([`ToneEntry::origin`]).
731    pub origin: String,
732    /// For a person: "two-tone 849.63 → 1292.85 Hz, 1 + 3.04 s (Quick Call II)".
733    pub text: String,
734    /// The system's signals it is (their ids).
735    #[serde(skip_serializing_if = "Vec::is_empty")]
736    pub signals: Vec<String>,
737    #[serde(flatten)]
738    pub extra: Map<String, Value>,
739}
740
741fn is_zero(v: &u32) -> bool {
742    *v == 0
743}
744
745// How the call JSON writes its numbers (as Trunk Recorder's files have
746// them): a flag as 0/1, a level to a tenth, a confidence to a thousandth, a
747// position to a millionth, seconds as Rust prints them ("4", not "4.0").
748
749fn number<S: serde::Serializer>(text: String, s: S) -> Result<S::Ok, S::Error> {
750    use serde::ser::Error;
751    serde_json::value::RawValue::from_string(text).map_err(S::Error::custom)?.serialize(s)
752}
753
754fn finite<S: serde::Serializer>(v: f64, text: impl FnOnce(f64) -> String, s: S) -> Result<S::Ok, S::Error> {
755    if v.is_finite() { number(text(v), s) } else { s.serialize_none() }
756}
757
758fn flag_out<S: serde::Serializer>(v: &bool, s: S) -> Result<S::Ok, S::Error> {
759    s.serialize_u8(*v as u8)
760}
761
762fn plain<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
763    finite(*v, |v| format!("{v}"), s)
764}
765
766fn thousandth<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
767    finite(*v, |v| format!("{v:.3}"), s)
768}
769
770fn tenth_or_null<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
771    match v {
772        Some(v) => finite(*v, |v| format!("{v:.1}"), s),
773        None => s.serialize_none(),
774    }
775}
776
777fn millionth<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
778    match v {
779        Some(v) => finite(*v, |v| format!("{v:.6}"), s),
780        None => s.serialize_none(),
781    }
782}
783
784/// Trunk Recorder writes flags as 0/1 and as booleans; take either.
785fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
786    Ok(match Value::deserialize(d)? {
787        Value::Bool(b) => b,
788        Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
789        Value::String(s) => s == "true" || s == "1",
790        _ => false,
791    })
792}
793
794/// A call's tones made a pattern — a page, an alert tone, a warble, a DTMF
795/// or KNOX string, a Morse ID — told as soon as nothing can add to it (a
796/// second after its last tone), whether or not it's one of the system's
797/// signals. The call's JSON (`tone_patterns`) has the same pattern once it's
798/// saved (its `pos` there in the saved audio, here in the call's so far).
799#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
800#[serde(default)]
801pub struct TonePatternEvent {
802    pub short_name: String,
803    #[serde(skip_serializing_if = "String::is_empty")]
804    pub site: String,
805    /// The call it's on (its `call_num`).
806    pub call_num: u64,
807    pub talkgroup: u32,
808    pub alpha_tag: String,
809    /// The radio talking (`src`)'s name, as a call's would be. Empty: none known.
810    #[serde(skip_serializing_if = "String::is_empty")]
811    pub src_tag: String,
812    /// Heard in voice decrypted with the system's keys: only plugins allowed
813    /// decrypted calls get it.
814    #[serde(skip_serializing_if = "std::ops::Not::not")]
815    pub decrypted: bool,
816    /// Unix seconds.
817    pub time: f64,
818    #[serde(flatten)]
819    pub pattern: TonePattern,
820}
821
822/// Something a radio did on the control channel.
823#[derive(Clone, Debug, Default, Serialize, Deserialize)]
824#[serde(default)]
825pub struct UnitEvent {
826    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
827    pub system: u16,
828    pub short_name: String,
829    /// The site whose control channel told it (its name).
830    #[serde(skip_serializing_if = "String::is_empty")]
831    pub site: String,
832    /// "registration" | "deregistration" | "affiliation" | "acknowledge" |
833    /// "location" | "data_grant" | "answer_request" | "call_alert" |
834    /// "emergency_alarm"
835    pub kind: String,
836    /// Radio ID.
837    pub unit: u32,
838    /// Its name, as a call's would be (the units file, its talker alias). Empty: none known.
839    #[serde(skip_serializing_if = "String::is_empty")]
840    pub tag: String,
841    /// The talkgroup involved (affiliation, location, answer request, call
842    /// alert, emergency alarm).
843    pub talkgroup: Option<u32>,
844    /// The talkgroup's name, from the system's talkgroup file. Empty: none.
845    #[serde(skip_serializing_if = "String::is_empty")]
846    pub alpha_tag: String,
847    /// The message said it is an emergency (an emergency alarm, a data
848    /// grant's options).
849    pub emergency: bool,
850    /// Unix seconds.
851    pub time: f64,
852}
853
854/// A slice of a recording call's audio: 16-bit mono PCM, little-endian, base64.
855#[derive(Clone, Debug, Default, Serialize, Deserialize)]
856#[serde(default)]
857pub struct AudioChunk {
858    pub call_id: u32,
859    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
860    pub system: u16,
861    pub short_name: String,
862    pub talkgroup: u32,
863    pub sample_rate: u32,
864    pub pcm: String,
865}
866
867impl AudioChunk {
868    pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
869        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
870        AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
871    }
872    /// The samples.
873    pub fn samples(&self) -> Vec<i16> {
874        let b = base64::decode(&self.pcm);
875        b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
876    }
877}
878
879#[derive(Clone, Debug, Default, Serialize, Deserialize)]
880#[serde(default)]
881pub struct Status {
882    /// Unix seconds.
883    pub time: f64,
884    pub systems: Vec<SystemStatus>,
885}
886
887#[derive(Clone, Debug, Default, Serialize, Deserialize)]
888#[serde(default)]
889pub struct SystemStatus {
890    pub index: u16,
891    pub short_name: String,
892    /// One entry per site: this one's name (a system of one site: its short
893    /// name). `site` below is its number, as the control channel says it.
894    pub site_name: String,
895    /// The control channel being decoded (none: searching).
896    pub control_channel_hz: Option<u64>,
897    /// Control channel messages decoded per second.
898    pub decode_rate: f64,
899    pub active_calls: u32,
900    pub recording: u32,
901    /// The system's identity, as far as its control channel has stated it
902    /// (none until it has): P25 NAC, WACN, system ID, RFSS and site; site
903    /// also for SmartNet when its OBT sends one.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub nac: Option<u32>,
906    #[serde(skip_serializing_if = "Option::is_none")]
907    pub wacn: Option<u32>,
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub sys_id: Option<u32>,
910    #[serde(skip_serializing_if = "Option::is_none")]
911    pub rfss: Option<u32>,
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub site: Option<u32>,
914}
915
916#[derive(Clone, Debug, Default, Serialize, Deserialize)]
917#[serde(default)]
918pub struct Shutdown {
919    /// Seconds the plugin has to exit.
920    pub grace_s: f64,
921}
922
923/// A button of the plugin's was pressed ([`HostMessage::Action`]).
924#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
925#[serde(default)]
926pub struct Action {
927    /// Echo it in the [`PluginMessage::ActionResult`].
928    pub request: u64,
929    /// The action's `id`, as its `x-actions` entry names it.
930    pub action: String,
931    /// The settings object the button is on, as edited (saved or not): an
932    /// item of an array (a destination, to test it), or the whole settings.
933    pub value: Value,
934}
935
936// ─── Plugin → recorder ────────────────────────────────────────────────────
937
938#[derive(Clone, Debug, Serialize, Deserialize)]
939#[serde(tag = "type")]
940pub enum PluginMessage {
941    /// The plugin read its hello and is running.
942    #[serde(rename = "ready")]
943    Ready,
944    #[serde(rename = "log")]
945    Log { level: Level, message: String },
946    /// The plugin's health, shown next to it in the interface.
947    #[serde(rename = "status")]
948    Status {
949        state: State,
950        #[serde(default)]
951        message: String,
952    },
953    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
954    #[serde(rename = "call.result")]
955    CallResult {
956        path: String,
957        outcome: Outcome,
958        #[serde(default, skip_serializing_if = "String::is_empty")]
959        message: String,
960        /// Where the call can be found now (a link to it on the service).
961        #[serde(default, skip_serializing_if = "String::is_empty")]
962        url: String,
963        /// How long the work on it took, ms (an upload: its last try, so
964        /// retries count as waiting). The recorder's delivery timing splits
965        /// a call's age into waiting and uploading by it; without it, it
966        /// shows the age alone. [`crate::CallQueue`] says it.
967        #[serde(default, skip_serializing_if = "Option::is_none")]
968        took_ms: Option<u64>,
969    },
970    /// How the plugin's work is going, for the dashboard: queue, results,
971    /// timing and the services it talks to. Send it when it changes, at most
972    /// every few seconds ([`crate::CallQueue`] does it for you).
973    #[serde(rename = "metrics")]
974    Metrics(Metrics),
975    /// How an [`Action`] went: shown by the button that was pressed.
976    #[serde(rename = "action.result")]
977    ActionResult {
978        request: u64,
979        ok: bool,
980        #[serde(default, skip_serializing_if = "String::is_empty")]
981        message: String,
982    },
983    #[serde(other)]
984    Unknown,
985}
986
987/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
988/// send what you know. Totals are since the plugin started.
989#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
990#[serde(default, rename_all = "camelCase")]
991pub struct Metrics {
992    /// Calls waiting to be worked on (ready, or waiting to retry).
993    #[serde(skip_serializing_if = "Option::is_none")]
994    pub queued: Option<u64>,
995    /// Of those, waiting to retry after a failure.
996    #[serde(skip_serializing_if = "Option::is_none")]
997    pub retrying: Option<u64>,
998    /// Being worked on now.
999    #[serde(skip_serializing_if = "Option::is_none")]
1000    pub in_flight: Option<u64>,
1001    /// Tries that failed and will be retried, in total.
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub retries: Option<u64>,
1004    /// Bytes sent, in total.
1005    #[serde(skip_serializing_if = "Option::is_none")]
1006    pub bytes_sent: Option<u64>,
1007    /// How long one piece of work takes (an upload), ms, smoothed.
1008    #[serde(skip_serializing_if = "Option::is_none")]
1009    pub latency_ms: Option<f64>,
1010    /// Unix seconds of the last success and the last failure.
1011    #[serde(skip_serializing_if = "Option::is_none")]
1012    pub last_ok: Option<f64>,
1013    #[serde(skip_serializing_if = "Option::is_none")]
1014    pub last_error: Option<f64>,
1015    #[serde(skip_serializing_if = "String::is_empty")]
1016    pub last_error_text: String,
1017    /// The services it talks to.
1018    #[serde(skip_serializing_if = "Vec::is_empty")]
1019    pub endpoints: Vec<Endpoint>,
1020    /// Anything else worth showing, by name (shown as it is).
1021    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1022    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
1023}
1024
1025/// A service a plugin talks to, and how it's answering.
1026#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
1027#[serde(default, rename_all = "camelCase")]
1028pub struct Endpoint {
1029    /// What it is ("Broadcastify Calls", a host name).
1030    pub name: String,
1031    pub state: EndpointState,
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    pub latency_ms: Option<f64>,
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    pub last_ok: Option<f64>,
1036    #[serde(skip_serializing_if = "String::is_empty")]
1037    pub last_error: String,
1038}
1039
1040#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
1041#[serde(rename_all = "lowercase")]
1042pub enum EndpointState {
1043    #[default]
1044    Unknown,
1045    /// Answering.
1046    Up,
1047    /// Answering, with failures (retries).
1048    Degraded,
1049    /// Not answering.
1050    Down,
1051}
1052
1053#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1054#[serde(rename_all = "lowercase")]
1055pub enum Level {
1056    Error,
1057    Warn,
1058    Info,
1059    Debug,
1060}
1061
1062#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1063#[serde(rename_all = "lowercase")]
1064pub enum State {
1065    /// Working.
1066    Ok,
1067    /// Working, but something needs attention (a service down, calls queued).
1068    Warning,
1069    /// Not working (bad settings, nothing it can do).
1070    Error,
1071}
1072
1073#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1074#[serde(rename_all = "lowercase")]
1075pub enum Outcome {
1076    /// Done (uploaded, sent, stored).
1077    Ok,
1078    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
1079    Skipped,
1080    /// Gave up on it.
1081    Failed,
1082}
1083
1084/// Standard base64 (for [`AudioChunk::pcm`]).
1085pub mod base64 {
1086    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1087
1088    pub fn encode(b: &[u8]) -> String {
1089        let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
1090        for c in b.chunks(3) {
1091            let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
1092            for i in 0..4 {
1093                if i <= c.len() {
1094                    o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
1095                } else {
1096                    o.push('=');
1097                }
1098            }
1099        }
1100        o
1101    }
1102
1103    /// Invalid characters are skipped.
1104    pub fn decode(s: &str) -> Vec<u8> {
1105        let mut o = Vec::with_capacity(s.len() / 4 * 3);
1106        let (mut acc, mut bits) = (0u32, 0);
1107        for ch in s.bytes() {
1108            let v = match ch {
1109                b'A'..=b'Z' => ch - b'A',
1110                b'a'..=b'z' => ch - b'a' + 26,
1111                b'0'..=b'9' => ch - b'0' + 52,
1112                b'+' => 62,
1113                b'/' => 63,
1114                _ => continue,
1115            };
1116            acc = acc << 6 | v as u32;
1117            bits += 6;
1118            if bits >= 8 {
1119                bits -= 8;
1120                o.push((acc >> bits) as u8);
1121            }
1122        }
1123        o
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    /// A Trunk Recorder file (flags as booleans, a field this crate doesn't
1132    /// name) reads; written back, flags are 0/1 as the recorder's own files
1133    /// have them, levels to a tenth, seconds as written, and the unknown field kept.
1134    #[test]
1135    fn a_call_record_round_trips_as_the_recorders_files_are() {
1136        let tr = r#"{"freq":851012500,"signal":-31.24,"snr":null,"emergency":true,"encrypted":false,"call_length":4,"talkgroup":101,"short_name":"dcfd",
1137            "srcList":[{"src":7,"pos":1.5,"emergency":false,"lat":30.0529}],"freqList":[{"freq":851012500,"len":4.0}],"rec_state_type":"STOPPED"}"#;
1138        let r: CallRecord = serde_json::from_str(tr).unwrap();
1139        assert!(r.emergency && !r.encrypted && r.signal == Some(-31.24) && r.snr.is_none());
1140        let back = r.to_json();
1141        assert!(back.contains("\"signal\":-31.2,\"noise\":null,\"snr\":null,"), "{back}");
1142        assert!(back.contains("\"emergency\":1,") && back.contains("\"encrypted\":0,"), "{back}");
1143        assert!(back.contains("\"call_length\":4,") && back.contains("\"len\":4,"), "{back}");
1144        assert!(back.contains("\"pos\":1.5,\"emergency\":0,") && back.contains("\"lat\":30.052900"), "{back}");
1145        assert!(back.ends_with("\"rec_state_type\":\"STOPPED\"}"), "{back}");
1146        // Read and written again, it's the same.
1147        assert_eq!(serde_json::from_str::<CallRecord>(&back).unwrap().to_json(), back);
1148    }
1149
1150    #[test]
1151    fn base64_roundtrip() {
1152        for n in 0..10 {
1153            let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
1154            assert_eq!(base64::decode(&base64::encode(&b)), b);
1155        }
1156        assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
1157    }
1158
1159    #[test]
1160    fn unknown_types_and_fields_are_ignored() {
1161        let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
1162        assert!(matches!(m, HostMessage::Unknown));
1163        let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
1164        assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
1165    }
1166
1167    #[test]
1168    fn call_record_reads_trunk_recorder_json() {
1169        let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
1170            "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
1171            "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
1172            "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
1173            "color_code":-1,"patched_talkgroups":[101,65001]}"#;
1174        let c: CallRecord = serde_json::from_str(j).unwrap();
1175        assert!(c.encrypted && !c.emergency);
1176        assert_eq!(c.patched_talkgroups, [101, 65001]);
1177        assert_eq!((c.error_count(), c.spike_count()), (3, 1));
1178        assert_eq!(c.src_list[0].tag_ota, "E1");
1179        assert_eq!(c.color_code, -1);
1180        // And back.
1181        let v = serde_json::to_value(&c).unwrap();
1182        assert_eq!(v["color_code"], -1);
1183        assert_eq!(v["srcList"][0]["src"], 1234);
1184        assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
1185        let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
1186        assert!(unpatched.get("patched_talkgroups").is_none());
1187    }
1188
1189    #[test]
1190    fn system_identity_is_there_once_stated() {
1191        // From a recorder that doesn't send it: none of it.
1192        let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
1193        assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
1194        // Nothing stated yet: nothing written.
1195        let v = serde_json::to_value(&s).unwrap();
1196        assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
1197        let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
1198        let v = serde_json::to_value(&s).unwrap();
1199        assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
1200    }
1201}