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    pub talkgroup: u32,
194    /// The talkgroup's alpha tag, when known.
195    pub talkgroup_tag: String,
196    pub freq_hz: u64,
197    /// The TDMA slot (0 / 1) of a call on a two-slot channel: P25 Phase 2 or DMR.
198    pub tdma_slot: Option<u8>,
199    pub analog: bool,
200    pub encrypted: bool,
201    pub emergency: bool,
202    /// Recorded (false: only followed, or not recorded — see `reason`).
203    pub recording: bool,
204    /// Why it isn't recorded: "encrypted", "unknown_tg", "no_recorder", "no_source", …
205    pub reason: Option<String>,
206    /// Unix seconds.
207    pub start_time: f64,
208    /// Radios heard on the call so far.
209    pub units: Vec<u32>,
210    /// Talkgroups patched with this one so far, its own included, ascending;
211    /// empty when it isn't patched.
212    pub patched_talkgroups: Vec<u32>,
213}
214
215/// A recorded call whose files are on disk.
216#[derive(Clone, Debug, Default, Serialize, Deserialize)]
217#[serde(default)]
218pub struct ConcludedCall {
219    /// The call's key: its files' path relative to the capture folder, without
220    /// an extension (`<shortName>/<Y>/<M>/<D>/<tg>-<start>_<freq>`).
221    pub path: String,
222    /// [`SystemInfo::index`]: for this run only; the system's identity is `call.short_name`.
223    pub system: u16,
224    /// Its call JSON, in Trunk Recorder's format (the `.json` file's contents).
225    pub call: CallRecord,
226    pub files: CallFiles,
227}
228
229#[derive(Clone, Debug, Default, Serialize, Deserialize)]
230#[serde(default)]
231pub struct CallFiles {
232    pub json: PathBuf,
233    /// 16-bit mono WAV, 8 kHz. There whenever `m4a` isn't; with an .m4a it
234    /// may not be (the recorder writes the WAV only when it's kept, or a
235    /// plugin asks for [`format::WAV`]).
236    pub wav: PathBuf,
237    /// When the plugin asked for M4A and the recorder could encode it.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub m4a: Option<PathBuf>,
240}
241
242/// Trunk Recorder's call JSON. Fields this crate doesn't name are in `extra`.
243#[derive(Clone, Debug, Default, Serialize, Deserialize)]
244#[serde(default)]
245pub struct CallRecord {
246    pub call_num: u64,
247    pub short_name: String,
248    pub talkgroup: u32,
249    pub talkgroup_tag: String,
250    pub talkgroup_description: String,
251    pub talkgroup_group_tag: String,
252    pub talkgroup_group: String,
253    /// Hz.
254    pub freq: u64,
255    /// Unix seconds.
256    pub start_time: i64,
257    pub stop_time: i64,
258    /// Seconds of audio.
259    pub call_length: f64,
260    #[serde(deserialize_with = "flag")]
261    pub emergency: bool,
262    #[serde(deserialize_with = "flag")]
263    pub encrypted: bool,
264    pub priority: i64,
265    #[serde(deserialize_with = "flag")]
266    pub phase2_tdma: bool,
267    pub tdma_slot: i64,
268    /// "digital" | "digital tdma" | "analog"
269    pub audio_type: String,
270    #[serde(rename = "freqList")]
271    pub freq_list: Vec<FreqEntry>,
272    #[serde(rename = "srcList")]
273    pub src_list: Vec<SrcEntry>,
274    /// Every talkgroup patched with this one during the call, its own
275    /// included; absent (empty) when it wasn't patched with another.
276    #[serde(skip_serializing_if = "Vec::is_empty")]
277    pub patched_talkgroups: Vec<u32>,
278    #[serde(flatten)]
279    pub extra: Map<String, Value>,
280}
281
282impl CallRecord {
283    /// Voice frames with bit errors, over the call.
284    pub fn error_count(&self) -> u64 {
285        self.freq_list.iter().map(|f| f.error_count).sum()
286    }
287    pub fn spike_count(&self) -> u64 {
288        self.freq_list.iter().map(|f| f.spike_count).sum()
289    }
290}
291
292#[derive(Clone, Debug, Default, Serialize, Deserialize)]
293#[serde(default)]
294pub struct FreqEntry {
295    pub freq: u64,
296    /// Unix seconds.
297    pub time: i64,
298    /// Seconds into the audio.
299    pub pos: f64,
300    /// Seconds.
301    pub len: f64,
302    pub error_count: u64,
303    pub spike_count: u64,
304    #[serde(flatten)]
305    pub extra: Map<String, Value>,
306}
307
308/// A radio heard on the call.
309#[derive(Clone, Debug, Default, Serialize, Deserialize)]
310#[serde(default)]
311pub struct SrcEntry {
312    /// Radio ID.
313    pub src: i64,
314    /// Unix seconds.
315    pub time: i64,
316    /// Seconds into the audio.
317    pub pos: f64,
318    #[serde(deserialize_with = "flag")]
319    pub emergency: bool,
320    pub signal_system: String,
321    /// The radio's name from the unit tags file.
322    pub tag: String,
323    /// Its talker alias, heard over the air.
324    pub tag_ota: String,
325    /// Where it said it was (GPS, decimal degrees), when it did.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub lat: Option<f64>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub lon: Option<f64>,
330    #[serde(flatten)]
331    pub extra: Map<String, Value>,
332}
333
334/// Trunk Recorder writes flags as 0/1 and as booleans; take either.
335fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
336    Ok(match Value::deserialize(d)? {
337        Value::Bool(b) => b,
338        Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
339        Value::String(s) => s == "true" || s == "1",
340        _ => false,
341    })
342}
343
344/// Something a radio did on the control channel.
345#[derive(Clone, Debug, Default, Serialize, Deserialize)]
346#[serde(default)]
347pub struct UnitEvent {
348    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
349    pub system: u16,
350    pub short_name: String,
351    /// "registration" | "deregistration" | "affiliation" | "acknowledge" |
352    /// "location" | "data_grant" | "answer_request" | "call_alert" |
353    /// "emergency_alarm"
354    pub kind: String,
355    /// Radio ID.
356    pub unit: u32,
357    /// The talkgroup involved (affiliation, location, answer request, call
358    /// alert, emergency alarm).
359    pub talkgroup: Option<u32>,
360    /// Unix seconds.
361    pub time: f64,
362}
363
364/// A slice of a recording call's audio: 16-bit mono PCM, little-endian, base64.
365#[derive(Clone, Debug, Default, Serialize, Deserialize)]
366#[serde(default)]
367pub struct AudioChunk {
368    pub call_id: u32,
369    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
370    pub system: u16,
371    pub short_name: String,
372    pub talkgroup: u32,
373    pub sample_rate: u32,
374    pub pcm: String,
375}
376
377impl AudioChunk {
378    pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
379        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
380        AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
381    }
382    /// The samples.
383    pub fn samples(&self) -> Vec<i16> {
384        let b = base64::decode(&self.pcm);
385        b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
386    }
387}
388
389#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390#[serde(default)]
391pub struct Status {
392    /// Unix seconds.
393    pub time: f64,
394    pub systems: Vec<SystemStatus>,
395}
396
397#[derive(Clone, Debug, Default, Serialize, Deserialize)]
398#[serde(default)]
399pub struct SystemStatus {
400    pub index: u16,
401    pub short_name: String,
402    /// The control channel being decoded (none: searching).
403    pub control_channel_hz: Option<u64>,
404    /// Control channel messages decoded per second.
405    pub decode_rate: f64,
406    pub active_calls: u32,
407    pub recording: u32,
408}
409
410#[derive(Clone, Debug, Default, Serialize, Deserialize)]
411#[serde(default)]
412pub struct Shutdown {
413    /// Seconds the plugin has to exit.
414    pub grace_s: f64,
415}
416
417// ─── Plugin → recorder ────────────────────────────────────────────────────
418
419#[derive(Clone, Debug, Serialize, Deserialize)]
420#[serde(tag = "type")]
421pub enum PluginMessage {
422    /// The plugin read its hello and is running.
423    #[serde(rename = "ready")]
424    Ready,
425    #[serde(rename = "log")]
426    Log { level: Level, message: String },
427    /// The plugin's health, shown next to it in the interface.
428    #[serde(rename = "status")]
429    Status {
430        state: State,
431        #[serde(default)]
432        message: String,
433    },
434    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
435    #[serde(rename = "call.result")]
436    CallResult {
437        path: String,
438        outcome: Outcome,
439        #[serde(default, skip_serializing_if = "String::is_empty")]
440        message: String,
441        /// Where the call can be found now (a link to it on the service).
442        #[serde(default, skip_serializing_if = "String::is_empty")]
443        url: String,
444    },
445    /// How the plugin's work is going, for the dashboard: queue, results,
446    /// timing and the services it talks to. Send it when it changes, at most
447    /// every few seconds ([`crate::CallQueue`] does it for you).
448    #[serde(rename = "metrics")]
449    Metrics(Metrics),
450    #[serde(other)]
451    Unknown,
452}
453
454/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
455/// send what you know. Totals are since the plugin started.
456#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
457#[serde(default, rename_all = "camelCase")]
458pub struct Metrics {
459    /// Calls waiting to be worked on (ready, or waiting to retry).
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub queued: Option<u64>,
462    /// Of those, waiting to retry after a failure.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub retrying: Option<u64>,
465    /// Being worked on now.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub in_flight: Option<u64>,
468    /// Tries that failed and will be retried, in total.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub retries: Option<u64>,
471    /// Bytes sent, in total.
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub bytes_sent: Option<u64>,
474    /// How long one piece of work takes (an upload), ms, smoothed.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub latency_ms: Option<f64>,
477    /// Unix seconds of the last success and the last failure.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub last_ok: Option<f64>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub last_error: Option<f64>,
482    #[serde(skip_serializing_if = "String::is_empty")]
483    pub last_error_text: String,
484    /// The services it talks to.
485    #[serde(skip_serializing_if = "Vec::is_empty")]
486    pub endpoints: Vec<Endpoint>,
487    /// Anything else worth showing, by name (shown as it is).
488    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
489    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
490}
491
492/// A service a plugin talks to, and how it's answering.
493#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
494#[serde(default, rename_all = "camelCase")]
495pub struct Endpoint {
496    /// What it is ("Broadcastify Calls", a host name).
497    pub name: String,
498    pub state: EndpointState,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub latency_ms: Option<f64>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub last_ok: Option<f64>,
503    #[serde(skip_serializing_if = "String::is_empty")]
504    pub last_error: String,
505}
506
507#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "lowercase")]
509pub enum EndpointState {
510    #[default]
511    Unknown,
512    /// Answering.
513    Up,
514    /// Answering, with failures (retries).
515    Degraded,
516    /// Not answering.
517    Down,
518}
519
520#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
521#[serde(rename_all = "lowercase")]
522pub enum Level {
523    Error,
524    Warn,
525    Info,
526    Debug,
527}
528
529#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "lowercase")]
531pub enum State {
532    /// Working.
533    Ok,
534    /// Working, but something needs attention (a service down, calls queued).
535    Warning,
536    /// Not working (bad settings, nothing it can do).
537    Error,
538}
539
540#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
541#[serde(rename_all = "lowercase")]
542pub enum Outcome {
543    /// Done (uploaded, sent, stored).
544    Ok,
545    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
546    Skipped,
547    /// Gave up on it.
548    Failed,
549}
550
551/// Standard base64 (for [`AudioChunk::pcm`]).
552pub mod base64 {
553    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
554
555    pub fn encode(b: &[u8]) -> String {
556        let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
557        for c in b.chunks(3) {
558            let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
559            for i in 0..4 {
560                if i <= c.len() {
561                    o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
562                } else {
563                    o.push('=');
564                }
565            }
566        }
567        o
568    }
569
570    /// Invalid characters are skipped.
571    pub fn decode(s: &str) -> Vec<u8> {
572        let mut o = Vec::with_capacity(s.len() / 4 * 3);
573        let (mut acc, mut bits) = (0u32, 0);
574        for ch in s.bytes() {
575            let v = match ch {
576                b'A'..=b'Z' => ch - b'A',
577                b'a'..=b'z' => ch - b'a' + 26,
578                b'0'..=b'9' => ch - b'0' + 52,
579                b'+' => 62,
580                b'/' => 63,
581                _ => continue,
582            };
583            acc = acc << 6 | v as u32;
584            bits += 6;
585            if bits >= 8 {
586                bits -= 8;
587                o.push((acc >> bits) as u8);
588            }
589        }
590        o
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn base64_roundtrip() {
600        for n in 0..10 {
601            let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
602            assert_eq!(base64::decode(&base64::encode(&b)), b);
603        }
604        assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
605    }
606
607    #[test]
608    fn unknown_types_and_fields_are_ignored() {
609        let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
610        assert!(matches!(m, HostMessage::Unknown));
611        let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
612        assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
613    }
614
615    #[test]
616    fn call_record_reads_trunk_recorder_json() {
617        let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
618            "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
619            "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
620            "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
621            "color_code":-1,"patched_talkgroups":[101,65001]}"#;
622        let c: CallRecord = serde_json::from_str(j).unwrap();
623        assert!(c.encrypted && !c.emergency);
624        assert_eq!(c.patched_talkgroups, [101, 65001]);
625        assert_eq!((c.error_count(), c.spike_count()), (3, 1));
626        assert_eq!(c.src_list[0].tag_ota, "E1");
627        assert_eq!(c.extra["color_code"], -1);
628        // And back, with the unknown field kept.
629        let v = serde_json::to_value(&c).unwrap();
630        assert_eq!(v["color_code"], -1);
631        assert_eq!(v["srcList"][0]["src"], 1234);
632        assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
633        let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
634        assert!(unpatched.get("patched_talkgroups").is_none());
635    }
636}