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`]). WAV is always there.
44pub mod format {
45    /// AAC in an MP4 container (OpenMHz, Broadcastify Calls).
46    pub const M4A: &str = "m4a";
47}
48
49/// Who a plugin is and what it wants — printed by `plugin --describe`.
50#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
51#[serde(default)]
52pub struct Manifest {
53    /// Unique, lowercase, `a-z0-9-` (the registry key and the install folder).
54    pub id: String,
55    /// Shown in the interface.
56    pub name: String,
57    /// Semver of the plugin.
58    pub version: String,
59    pub description: String,
60    /// The protocol version the plugin speaks ([`API_VERSION`]).
61    pub api: u32,
62    /// Topics ([`topic`]) to receive. Nothing else is sent — or even built.
63    pub subscribe: Vec<String>,
64    /// Extra audio formats ([`format`](mod@format)) for `call.concluded`. The recorder
65    /// encodes each call once for every plugin that asks, when it can.
66    pub audio_formats: Vec<String>,
67    /// JSON Schema of the plugin's settings (an object), for the settings form.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub config: Option<Value>,
70    /// JSON Schema of the settings the plugin takes for each system (an
71    /// object); the form repeats it under each system.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub system_config: Option<Value>,
74    #[serde(skip_serializing_if = "String::is_empty")]
75    pub homepage: String,
76    #[serde(skip_serializing_if = "String::is_empty")]
77    pub repository: String,
78    #[serde(skip_serializing_if = "Vec::is_empty")]
79    pub authors: Vec<String>,
80    #[serde(skip_serializing_if = "String::is_empty")]
81    pub license: String,
82}
83
84impl Manifest {
85    pub fn subscribes(&self, topic: &str) -> bool {
86        self.subscribe.iter().any(|t| t == topic)
87    }
88    pub fn wants_format(&self, format: &str) -> bool {
89        self.audio_formats.iter().any(|f| f == format)
90    }
91}
92
93// ─── Recorder → plugin ────────────────────────────────────────────────────
94
95// (Messages are read, handled and dropped one at a time: their size doesn't matter.)
96#[allow(clippy::large_enum_variant)]
97#[derive(Clone, Debug, Serialize, Deserialize)]
98#[serde(tag = "type")]
99pub enum HostMessage {
100    /// Always first, once.
101    #[serde(rename = "hello")]
102    Hello(Hello),
103    #[serde(rename = "call.start")]
104    CallStart(CallInfo),
105    #[serde(rename = "call.end")]
106    CallEnd(CallInfo),
107    #[serde(rename = "call.concluded")]
108    CallConcluded(ConcludedCall),
109    #[serde(rename = "unit")]
110    Unit(UnitEvent),
111    #[serde(rename = "audio")]
112    Audio(AudioChunk),
113    #[serde(rename = "status")]
114    Status(Status),
115    /// The recorder is stopping: finish what's queued, then exit. stdin closes
116    /// after this, and the process is killed if it hasn't exited within
117    /// [`Shutdown::grace_s`].
118    #[serde(rename = "shutdown")]
119    Shutdown(Shutdown),
120    /// A message type from a newer recorder.
121    #[serde(other)]
122    Unknown,
123}
124
125#[derive(Clone, Debug, Default, Serialize, Deserialize)]
126#[serde(default)]
127pub struct Hello {
128    /// The recorder's protocol version.
129    pub api: u32,
130    pub host: HostInfo,
131    /// The plugin's settings, as the user entered them (see [`Manifest::config`]).
132    pub config: Value,
133    /// Every system calls can come from, including conventional channels.
134    pub systems: Vec<SystemInfo>,
135    /// Where calls are stored.
136    pub capture_dir: PathBuf,
137    /// A folder of the plugin's own that survives restarts and upgrades (queues, state).
138    pub data_dir: PathBuf,
139    /// The audio formats `call.concluded` will carry this run: always "wav",
140    /// plus those the plugin asked for that the recorder can encode.
141    pub audio_formats: Vec<String>,
142}
143
144#[derive(Clone, Debug, Default, Serialize, Deserialize)]
145#[serde(default)]
146pub struct HostInfo {
147    pub name: String,
148    pub version: String,
149}
150
151/// The index calls and events use for the first conventional system; the
152/// next ones count down from it (65534, 65533, …). Each is a system of its
153/// own in [`Hello::systems`], with its own short name and settings.
154pub const CONVENTIONAL: u16 = 65535;
155
156#[derive(Clone, Debug, Default, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SystemInfo {
159    /// The number events carry for it (`system`) while the recorder runs
160    /// (conventional systems: [`CONVENTIONAL`] and down). It can change
161    /// between runs: know systems by `short_name`.
162    pub index: u16,
163    /// The system's short name: its identity (unique among all the
164    /// recorder's systems), its folder, and what users know it by. Every
165    /// event carries it.
166    pub short_name: String,
167    /// "p25" | "smartnet" | "dmr" | "conventional"
168    pub kind: String,
169    /// The plugin's settings for this system (see [`Manifest::system_config`]);
170    /// null when the user left them empty.
171    pub config: Value,
172}
173
174/// A call as it starts or ends.
175#[derive(Clone, Debug, Default, Serialize, Deserialize)]
176#[serde(default)]
177pub struct CallInfo {
178    /// Unique while the recorder runs (Trunk Recorder's call_num).
179    pub id: u32,
180    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
181    pub system: u16,
182    pub short_name: String,
183    pub talkgroup: u32,
184    /// The talkgroup's alpha tag, when known.
185    pub talkgroup_tag: String,
186    pub freq_hz: u64,
187    /// The TDMA slot (0 / 1) of a call on a two-slot channel: P25 Phase 2 or DMR.
188    pub tdma_slot: Option<u8>,
189    pub analog: bool,
190    pub encrypted: bool,
191    pub emergency: bool,
192    /// Recorded (false: only followed, or not recorded — see `reason`).
193    pub recording: bool,
194    /// Why it isn't recorded: "encrypted", "unknown_tg", "no_recorder", "no_source", …
195    pub reason: Option<String>,
196    /// Unix seconds.
197    pub start_time: f64,
198    /// Radios heard on the call so far.
199    pub units: Vec<u32>,
200    /// Talkgroups patched with this one so far, its own included, ascending;
201    /// empty when it isn't patched.
202    pub patched_talkgroups: Vec<u32>,
203}
204
205/// A recorded call whose files are on disk.
206#[derive(Clone, Debug, Default, Serialize, Deserialize)]
207#[serde(default)]
208pub struct ConcludedCall {
209    /// The call's key: its files' path relative to the capture folder, without
210    /// an extension (`<shortName>/<Y>/<M>/<D>/<tg>-<start>_<freq>`).
211    pub path: String,
212    /// [`SystemInfo::index`]: for this run only; the system's identity is `call.short_name`.
213    pub system: u16,
214    /// Its call JSON, in Trunk Recorder's format (the `.json` file's contents).
215    pub call: CallRecord,
216    pub files: CallFiles,
217}
218
219#[derive(Clone, Debug, Default, Serialize, Deserialize)]
220#[serde(default)]
221pub struct CallFiles {
222    pub json: PathBuf,
223    /// 16-bit mono WAV, 8 kHz.
224    pub wav: PathBuf,
225    /// When the plugin asked for M4A and the recorder could encode it.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub m4a: Option<PathBuf>,
228}
229
230/// Trunk Recorder's call JSON. Fields this crate doesn't name are in `extra`.
231#[derive(Clone, Debug, Default, Serialize, Deserialize)]
232#[serde(default)]
233pub struct CallRecord {
234    pub call_num: u64,
235    pub short_name: String,
236    pub talkgroup: u32,
237    pub talkgroup_tag: String,
238    pub talkgroup_description: String,
239    pub talkgroup_group_tag: String,
240    pub talkgroup_group: String,
241    /// Hz.
242    pub freq: u64,
243    /// Unix seconds.
244    pub start_time: i64,
245    pub stop_time: i64,
246    /// Seconds of audio.
247    pub call_length: f64,
248    #[serde(deserialize_with = "flag")]
249    pub emergency: bool,
250    #[serde(deserialize_with = "flag")]
251    pub encrypted: bool,
252    pub priority: i64,
253    #[serde(deserialize_with = "flag")]
254    pub phase2_tdma: bool,
255    pub tdma_slot: i64,
256    /// "digital" | "digital tdma" | "analog"
257    pub audio_type: String,
258    #[serde(rename = "freqList")]
259    pub freq_list: Vec<FreqEntry>,
260    #[serde(rename = "srcList")]
261    pub src_list: Vec<SrcEntry>,
262    /// Every talkgroup patched with this one during the call, its own
263    /// included; absent (empty) when it wasn't patched with another.
264    #[serde(skip_serializing_if = "Vec::is_empty")]
265    pub patched_talkgroups: Vec<u32>,
266    #[serde(flatten)]
267    pub extra: Map<String, Value>,
268}
269
270impl CallRecord {
271    /// Voice frames with bit errors, over the call.
272    pub fn error_count(&self) -> u64 {
273        self.freq_list.iter().map(|f| f.error_count).sum()
274    }
275    pub fn spike_count(&self) -> u64 {
276        self.freq_list.iter().map(|f| f.spike_count).sum()
277    }
278}
279
280#[derive(Clone, Debug, Default, Serialize, Deserialize)]
281#[serde(default)]
282pub struct FreqEntry {
283    pub freq: u64,
284    /// Unix seconds.
285    pub time: i64,
286    /// Seconds into the audio.
287    pub pos: f64,
288    /// Seconds.
289    pub len: f64,
290    pub error_count: u64,
291    pub spike_count: u64,
292    #[serde(flatten)]
293    pub extra: Map<String, Value>,
294}
295
296/// A radio heard on the call.
297#[derive(Clone, Debug, Default, Serialize, Deserialize)]
298#[serde(default)]
299pub struct SrcEntry {
300    /// Radio ID.
301    pub src: i64,
302    /// Unix seconds.
303    pub time: i64,
304    /// Seconds into the audio.
305    pub pos: f64,
306    #[serde(deserialize_with = "flag")]
307    pub emergency: bool,
308    pub signal_system: String,
309    /// The radio's name from the unit tags file.
310    pub tag: String,
311    /// Its talker alias, heard over the air.
312    pub tag_ota: String,
313    #[serde(flatten)]
314    pub extra: Map<String, Value>,
315}
316
317/// Trunk Recorder writes flags as 0/1 and as booleans; take either.
318fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
319    Ok(match Value::deserialize(d)? {
320        Value::Bool(b) => b,
321        Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
322        Value::String(s) => s == "true" || s == "1",
323        _ => false,
324    })
325}
326
327/// Something a radio did on the control channel.
328#[derive(Clone, Debug, Default, Serialize, Deserialize)]
329#[serde(default)]
330pub struct UnitEvent {
331    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
332    pub system: u16,
333    pub short_name: String,
334    /// "registration" | "deregistration" | "affiliation" | "acknowledge" |
335    /// "location" | "data_grant" | "answer_request" | "call_alert"
336    pub kind: String,
337    /// Radio ID.
338    pub unit: u32,
339    /// The talkgroup involved (affiliation, location, answer request, call alert).
340    pub talkgroup: Option<u32>,
341    /// Unix seconds.
342    pub time: f64,
343}
344
345/// A slice of a recording call's audio: 16-bit mono PCM, little-endian, base64.
346#[derive(Clone, Debug, Default, Serialize, Deserialize)]
347#[serde(default)]
348pub struct AudioChunk {
349    pub call_id: u32,
350    /// [`SystemInfo::index`]: for this run only; `short_name` is the system's identity.
351    pub system: u16,
352    pub short_name: String,
353    pub talkgroup: u32,
354    pub sample_rate: u32,
355    pub pcm: String,
356}
357
358impl AudioChunk {
359    pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
360        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
361        AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
362    }
363    /// The samples.
364    pub fn samples(&self) -> Vec<i16> {
365        let b = base64::decode(&self.pcm);
366        b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
367    }
368}
369
370#[derive(Clone, Debug, Default, Serialize, Deserialize)]
371#[serde(default)]
372pub struct Status {
373    /// Unix seconds.
374    pub time: f64,
375    pub systems: Vec<SystemStatus>,
376}
377
378#[derive(Clone, Debug, Default, Serialize, Deserialize)]
379#[serde(default)]
380pub struct SystemStatus {
381    pub index: u16,
382    pub short_name: String,
383    /// The control channel being decoded (none: searching).
384    pub control_channel_hz: Option<u64>,
385    /// Control channel messages decoded per second.
386    pub decode_rate: f64,
387    pub active_calls: u32,
388    pub recording: u32,
389}
390
391#[derive(Clone, Debug, Default, Serialize, Deserialize)]
392#[serde(default)]
393pub struct Shutdown {
394    /// Seconds the plugin has to exit.
395    pub grace_s: f64,
396}
397
398// ─── Plugin → recorder ────────────────────────────────────────────────────
399
400#[derive(Clone, Debug, Serialize, Deserialize)]
401#[serde(tag = "type")]
402pub enum PluginMessage {
403    /// The plugin read its hello and is running.
404    #[serde(rename = "ready")]
405    Ready,
406    #[serde(rename = "log")]
407    Log { level: Level, message: String },
408    /// The plugin's health, shown next to it in the interface.
409    #[serde(rename = "status")]
410    Status {
411        state: State,
412        #[serde(default)]
413        message: String,
414    },
415    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
416    #[serde(rename = "call.result")]
417    CallResult {
418        path: String,
419        outcome: Outcome,
420        #[serde(default, skip_serializing_if = "String::is_empty")]
421        message: String,
422        /// Where the call can be found now (a link to it on the service).
423        #[serde(default, skip_serializing_if = "String::is_empty")]
424        url: String,
425    },
426    /// How the plugin's work is going, for the dashboard: queue, results,
427    /// timing and the services it talks to. Send it when it changes, at most
428    /// every few seconds ([`crate::CallQueue`] does it for you).
429    #[serde(rename = "metrics")]
430    Metrics(Metrics),
431    #[serde(other)]
432    Unknown,
433}
434
435/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
436/// send what you know. Totals are since the plugin started.
437#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
438#[serde(default, rename_all = "camelCase")]
439pub struct Metrics {
440    /// Calls waiting to be worked on (ready, or waiting to retry).
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub queued: Option<u64>,
443    /// Of those, waiting to retry after a failure.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub retrying: Option<u64>,
446    /// Being worked on now.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub in_flight: Option<u64>,
449    /// Tries that failed and will be retried, in total.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub retries: Option<u64>,
452    /// Bytes sent, in total.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub bytes_sent: Option<u64>,
455    /// How long one piece of work takes (an upload), ms, smoothed.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub latency_ms: Option<f64>,
458    /// Unix seconds of the last success and the last failure.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub last_ok: Option<f64>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub last_error: Option<f64>,
463    #[serde(skip_serializing_if = "String::is_empty")]
464    pub last_error_text: String,
465    /// The services it talks to.
466    #[serde(skip_serializing_if = "Vec::is_empty")]
467    pub endpoints: Vec<Endpoint>,
468    /// Anything else worth showing, by name (shown as it is).
469    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
470    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
471}
472
473/// A service a plugin talks to, and how it's answering.
474#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
475#[serde(default, rename_all = "camelCase")]
476pub struct Endpoint {
477    /// What it is ("Broadcastify Calls", a host name).
478    pub name: String,
479    pub state: EndpointState,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub latency_ms: Option<f64>,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub last_ok: Option<f64>,
484    #[serde(skip_serializing_if = "String::is_empty")]
485    pub last_error: String,
486}
487
488#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
489#[serde(rename_all = "lowercase")]
490pub enum EndpointState {
491    #[default]
492    Unknown,
493    /// Answering.
494    Up,
495    /// Answering, with failures (retries).
496    Degraded,
497    /// Not answering.
498    Down,
499}
500
501#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
502#[serde(rename_all = "lowercase")]
503pub enum Level {
504    Error,
505    Warn,
506    Info,
507    Debug,
508}
509
510#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "lowercase")]
512pub enum State {
513    /// Working.
514    Ok,
515    /// Working, but something needs attention (a service down, calls queued).
516    Warning,
517    /// Not working (bad settings, nothing it can do).
518    Error,
519}
520
521#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
522#[serde(rename_all = "lowercase")]
523pub enum Outcome {
524    /// Done (uploaded, sent, stored).
525    Ok,
526    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
527    Skipped,
528    /// Gave up on it.
529    Failed,
530}
531
532/// Standard base64 (for [`AudioChunk::pcm`]).
533pub mod base64 {
534    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
535
536    pub fn encode(b: &[u8]) -> String {
537        let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
538        for c in b.chunks(3) {
539            let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
540            for i in 0..4 {
541                if i <= c.len() {
542                    o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
543                } else {
544                    o.push('=');
545                }
546            }
547        }
548        o
549    }
550
551    /// Invalid characters are skipped.
552    pub fn decode(s: &str) -> Vec<u8> {
553        let mut o = Vec::with_capacity(s.len() / 4 * 3);
554        let (mut acc, mut bits) = (0u32, 0);
555        for ch in s.bytes() {
556            let v = match ch {
557                b'A'..=b'Z' => ch - b'A',
558                b'a'..=b'z' => ch - b'a' + 26,
559                b'0'..=b'9' => ch - b'0' + 52,
560                b'+' => 62,
561                b'/' => 63,
562                _ => continue,
563            };
564            acc = acc << 6 | v as u32;
565            bits += 6;
566            if bits >= 8 {
567                bits -= 8;
568                o.push((acc >> bits) as u8);
569            }
570        }
571        o
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn base64_roundtrip() {
581        for n in 0..10 {
582            let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
583            assert_eq!(base64::decode(&base64::encode(&b)), b);
584        }
585        assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
586    }
587
588    #[test]
589    fn unknown_types_and_fields_are_ignored() {
590        let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
591        assert!(matches!(m, HostMessage::Unknown));
592        let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
593        assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
594    }
595
596    #[test]
597    fn call_record_reads_trunk_recorder_json() {
598        let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
599            "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
600            "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
601            "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
602            "color_code":-1,"patched_talkgroups":[101,65001]}"#;
603        let c: CallRecord = serde_json::from_str(j).unwrap();
604        assert!(c.encrypted && !c.emergency);
605        assert_eq!(c.patched_talkgroups, [101, 65001]);
606        assert_eq!((c.error_count(), c.spike_count()), (3, 1));
607        assert_eq!(c.src_list[0].tag_ota, "E1");
608        assert_eq!(c.extra["color_code"], -1);
609        // And back, with the unknown field kept.
610        let v = serde_json::to_value(&c).unwrap();
611        assert_eq!(v["color_code"], -1);
612        assert_eq!(v["srcList"][0]["src"], 1234);
613        assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
614        let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
615        assert!(unpatched.get("patched_talkgroups").is_none());
616    }
617}