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    /// Phase II TDMA slot.
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    pub system: u16,
351    pub talkgroup: u32,
352    pub sample_rate: u32,
353    pub pcm: String,
354}
355
356impl AudioChunk {
357    pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
358        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
359        AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes) }
360    }
361    /// The samples.
362    pub fn samples(&self) -> Vec<i16> {
363        let b = base64::decode(&self.pcm);
364        b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
365    }
366}
367
368#[derive(Clone, Debug, Default, Serialize, Deserialize)]
369#[serde(default)]
370pub struct Status {
371    /// Unix seconds.
372    pub time: f64,
373    pub systems: Vec<SystemStatus>,
374}
375
376#[derive(Clone, Debug, Default, Serialize, Deserialize)]
377#[serde(default)]
378pub struct SystemStatus {
379    pub index: u16,
380    pub short_name: String,
381    /// The control channel being decoded (none: searching).
382    pub control_channel_hz: Option<u64>,
383    /// Control channel messages decoded per second.
384    pub decode_rate: f64,
385    pub active_calls: u32,
386    pub recording: u32,
387}
388
389#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390#[serde(default)]
391pub struct Shutdown {
392    /// Seconds the plugin has to exit.
393    pub grace_s: f64,
394}
395
396// ─── Plugin → recorder ────────────────────────────────────────────────────
397
398#[derive(Clone, Debug, Serialize, Deserialize)]
399#[serde(tag = "type")]
400pub enum PluginMessage {
401    /// The plugin read its hello and is running.
402    #[serde(rename = "ready")]
403    Ready,
404    #[serde(rename = "log")]
405    Log { level: Level, message: String },
406    /// The plugin's health, shown next to it in the interface.
407    #[serde(rename = "status")]
408    Status {
409        state: State,
410        #[serde(default)]
411        message: String,
412    },
413    /// What became of a concluded call (`path`: [`ConcludedCall::path`]).
414    #[serde(rename = "call.result")]
415    CallResult {
416        path: String,
417        outcome: Outcome,
418        #[serde(default, skip_serializing_if = "String::is_empty")]
419        message: String,
420        /// Where the call can be found now (a link to it on the service).
421        #[serde(default, skip_serializing_if = "String::is_empty")]
422        url: String,
423    },
424    /// How the plugin's work is going, for the dashboard: queue, results,
425    /// timing and the services it talks to. Send it when it changes, at most
426    /// every few seconds ([`crate::CallQueue`] does it for you).
427    #[serde(rename = "metrics")]
428    Metrics(Metrics),
429    #[serde(other)]
430    Unknown,
431}
432
433/// A plugin's figures ([`PluginMessage::Metrics`]). Every field is optional:
434/// send what you know. Totals are since the plugin started.
435#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
436#[serde(default, rename_all = "camelCase")]
437pub struct Metrics {
438    /// Calls waiting to be worked on (ready, or waiting to retry).
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub queued: Option<u64>,
441    /// Of those, waiting to retry after a failure.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub retrying: Option<u64>,
444    /// Being worked on now.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub in_flight: Option<u64>,
447    /// Tries that failed and will be retried, in total.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub retries: Option<u64>,
450    /// Bytes sent, in total.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub bytes_sent: Option<u64>,
453    /// How long one piece of work takes (an upload), ms, smoothed.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub latency_ms: Option<f64>,
456    /// Unix seconds of the last success and the last failure.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub last_ok: Option<f64>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub last_error: Option<f64>,
461    #[serde(skip_serializing_if = "String::is_empty")]
462    pub last_error_text: String,
463    /// The services it talks to.
464    #[serde(skip_serializing_if = "Vec::is_empty")]
465    pub endpoints: Vec<Endpoint>,
466    /// Anything else worth showing, by name (shown as it is).
467    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
468    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
469}
470
471/// A service a plugin talks to, and how it's answering.
472#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
473#[serde(default, rename_all = "camelCase")]
474pub struct Endpoint {
475    /// What it is ("Broadcastify Calls", a host name).
476    pub name: String,
477    pub state: EndpointState,
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub latency_ms: Option<f64>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub last_ok: Option<f64>,
482    #[serde(skip_serializing_if = "String::is_empty")]
483    pub last_error: String,
484}
485
486#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
487#[serde(rename_all = "lowercase")]
488pub enum EndpointState {
489    #[default]
490    Unknown,
491    /// Answering.
492    Up,
493    /// Answering, with failures (retries).
494    Degraded,
495    /// Not answering.
496    Down,
497}
498
499#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
500#[serde(rename_all = "lowercase")]
501pub enum Level {
502    Error,
503    Warn,
504    Info,
505    Debug,
506}
507
508#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
509#[serde(rename_all = "lowercase")]
510pub enum State {
511    /// Working.
512    Ok,
513    /// Working, but something needs attention (a service down, calls queued).
514    Warning,
515    /// Not working (bad settings, nothing it can do).
516    Error,
517}
518
519#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
520#[serde(rename_all = "lowercase")]
521pub enum Outcome {
522    /// Done (uploaded, sent, stored).
523    Ok,
524    /// Deliberately not handled (a system it isn't set up for, a filtered talkgroup).
525    Skipped,
526    /// Gave up on it.
527    Failed,
528}
529
530/// Standard base64 (for [`AudioChunk::pcm`]).
531pub mod base64 {
532    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
533
534    pub fn encode(b: &[u8]) -> String {
535        let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
536        for c in b.chunks(3) {
537            let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
538            for i in 0..4 {
539                if i <= c.len() {
540                    o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
541                } else {
542                    o.push('=');
543                }
544            }
545        }
546        o
547    }
548
549    /// Invalid characters are skipped.
550    pub fn decode(s: &str) -> Vec<u8> {
551        let mut o = Vec::with_capacity(s.len() / 4 * 3);
552        let (mut acc, mut bits) = (0u32, 0);
553        for ch in s.bytes() {
554            let v = match ch {
555                b'A'..=b'Z' => ch - b'A',
556                b'a'..=b'z' => ch - b'a' + 26,
557                b'0'..=b'9' => ch - b'0' + 52,
558                b'+' => 62,
559                b'/' => 63,
560                _ => continue,
561            };
562            acc = acc << 6 | v as u32;
563            bits += 6;
564            if bits >= 8 {
565                bits -= 8;
566                o.push((acc >> bits) as u8);
567            }
568        }
569        o
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn base64_roundtrip() {
579        for n in 0..10 {
580            let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
581            assert_eq!(base64::decode(&base64::encode(&b)), b);
582        }
583        assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
584    }
585
586    #[test]
587    fn unknown_types_and_fields_are_ignored() {
588        let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
589        assert!(matches!(m, HostMessage::Unknown));
590        let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
591        assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
592    }
593
594    #[test]
595    fn call_record_reads_trunk_recorder_json() {
596        let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
597            "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
598            "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
599            "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
600            "color_code":-1,"patched_talkgroups":[101,65001]}"#;
601        let c: CallRecord = serde_json::from_str(j).unwrap();
602        assert!(c.encrypted && !c.emergency);
603        assert_eq!(c.patched_talkgroups, [101, 65001]);
604        assert_eq!((c.error_count(), c.spike_count()), (3, 1));
605        assert_eq!(c.src_list[0].tag_ota, "E1");
606        assert_eq!(c.extra["color_code"], -1);
607        // And back, with the unknown field kept.
608        let v = serde_json::to_value(&c).unwrap();
609        assert_eq!(v["color_code"], -1);
610        assert_eq!(v["srcList"][0]["src"], 1234);
611        assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
612        let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
613        assert!(unpatched.get("patched_talkgroups").is_none());
614    }
615}