Skip to main content

trunk_recorder_plugin/
sdk.rs

1//! Writing a plugin: implement [`Plugin`] and call [`run`] from `main`.
2
3use std::io::{self, BufRead, IsTerminal, Write};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7use schemars::JsonSchema;
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10
11use crate::protocol::*;
12use crate::schema;
13
14/// A plugin. Every method runs on the one thread that reads the recorder's
15/// messages, one message at a time: return quickly, and hand slow work
16/// (network, disk, other processes) to a thread of your own.
17pub trait Plugin: Sized {
18    /// The plugin's settings. `#[serde(default)]` on the struct lets a user
19    /// leave any of them empty. The settings form is drawn from its schema
20    /// (see [`crate::schema`]). [`NoConfig`] for none. A system can override
21    /// any of them (another server for it, say): read them for a system from
22    /// [`System::settings`], and from [`Setup::config`] only for what isn't
23    /// any one system's.
24    type Config: DeserializeOwned + JsonSchema;
25    /// Settings for each system, when the plugin needs some (an API key per
26    /// system, say). [`NoConfig`] for none.
27    type SystemConfig: DeserializeOwned + JsonSchema;
28
29    /// Who the plugin is and what it subscribes to — start from [`crate::manifest!`].
30    /// `config` and `system_config` are filled in from the types above.
31    fn manifest() -> Manifest;
32
33    /// Start with the user's settings. An `Err` is shown to the user, and the
34    /// plugin isn't started again until recording next starts.
35    fn start(host: Host, setup: Setup<Self::Config, Self::SystemConfig>) -> Result<Self, String>;
36
37    fn call_start(&mut self, _call: CallInfo) {}
38    fn call_end(&mut self, _call: CallInfo) {}
39    fn call_concluded(&mut self, _call: ConcludedCall) {}
40    fn unit(&mut self, _event: UnitEvent) {}
41    fn audio(&mut self, _chunk: AudioChunk) {}
42    fn status(&mut self, _status: Status) {}
43    /// A call's tones made a pattern: every one heard (subscribe to `tone_pattern`).
44    fn tone_pattern(&mut self, _t: TonePatternEvent) {}
45    /// One of the plugin's buttons was pressed (`x-actions` in its settings
46    /// schema). Answer through `reply`, now or from another thread once the
47    /// work is done; a reply dropped unsent answers that it failed.
48    fn action(&mut self, _a: Action, reply: Reply) {
49        reply.fail("This plugin has no such action");
50    }
51    /// Its settings changed while it runs (set [`Manifest::live_settings`]
52    /// for this to be called rather than the plugin restarted). An `Err` is
53    /// shown as its status, and the settings it had stay.
54    fn settings(&mut self, _setup: Setup<Self::Config, Self::SystemConfig>) -> Result<(), String> {
55        Err("it can't take new settings while it runs".into())
56    }
57
58    /// The recorder is stopping (or the plugin's stdin closed): finish or save
59    /// what's in flight within `grace` — the process exits when this returns.
60    fn shutdown(&mut self, _grace: std::time::Duration) {}
61}
62
63pub use crate::schema::NoConfig;
64
65/// What [`Plugin::start`] gets.
66#[derive(Clone, Debug)]
67pub struct Setup<C, S> {
68    /// The plugin's settings for the whole recorder (each system's, with
69    /// its overrides: [`System::settings`]).
70    pub config: C,
71    /// Every system, with the plugin's settings for it.
72    pub systems: Vec<System<C, S>>,
73    pub host: HostInfo,
74    pub capture_dir: PathBuf,
75    /// The plugin's own folder, kept across restarts and upgrades.
76    pub data_dir: PathBuf,
77    /// Audio formats `call.concluded` will carry (always "wav").
78    pub audio_formats: Vec<String>,
79}
80
81impl<C, S> Setup<C, S> {
82    /// The system an event's `system` names. (Its number is for this run
83    /// only; keep anything you store by [`System::short_name`].)
84    pub fn system(&self, index: u16) -> Option<&System<C, S>> {
85        self.systems.iter().find(|s| s.index == index)
86    }
87    /// The system with this short name — its identity: every event carries
88    /// it (`short_name`, or a concluded call's `call.short_name`).
89    pub fn system_named(&self, short_name: &str) -> Option<&System<C, S>> {
90        self.systems.iter().find(|s| s.short_name == short_name)
91    }
92    /// Whether `call.concluded` will carry `format` ([`format`](mod@format)).
93    pub fn has_format(&self, format: &str) -> bool {
94        self.audio_formats.iter().any(|f| f == format)
95    }
96}
97
98#[derive(Clone, Debug)]
99pub struct System<C, S> {
100    /// The number events carry for it, for this run only.
101    pub index: u16,
102    /// Its identity: unique among the recorder's systems, and what users know it by.
103    pub short_name: String,
104    /// "p25" | "smartnet" | "dmr" | "nxdn" | "edacs" | "conventional"
105    pub kind: String,
106    /// What people call it; empty when the user gave no name.
107    pub name: String,
108    /// Its FCC licenses by frequency (Hz, as a string), and the call sign
109    /// (and licensee) that names it: see [`SystemInfo::fcc`].
110    pub fcc: std::collections::BTreeMap<String, FccLicense>,
111    pub fcc_call_sign: String,
112    pub fcc_name: String,
113    /// Its sites.
114    pub sites: Vec<SiteInfo>,
115    /// The plugin's settings for this system (`None`: left empty).
116    pub config: Option<S>,
117    /// The plugin's own settings ([`Setup::config`]) as they are for this
118    /// system, with any it overrides.
119    pub settings: C,
120}
121
122/// The way back to the recorder: logs, health, results. Cheap to clone and
123/// usable from any thread. (A plugin's stdout belongs to the protocol — never
124/// `println!` in a plugin; use this, or `eprintln!` for the raw log.)
125#[derive(Clone)]
126pub struct Host {
127    out: Arc<Mutex<Box<dyn Write + Send>>>,
128}
129
130impl Host {
131    /// A host writing to `w` instead of stdout (see [`crate::testing`]).
132    pub fn to_writer(w: impl Write + Send + 'static) -> Host {
133        Host { out: Arc::new(Mutex::new(Box::new(w))) }
134    }
135
136    pub fn send(&self, m: &PluginMessage) {
137        let mut line = serde_json::to_string(m).unwrap_or_default();
138        line.push('\n');
139        let mut o = self.out.lock().unwrap_or_else(|e| e.into_inner());
140        // The recorder has gone when this fails; stdin's end will stop us.
141        let _ = o.write_all(line.as_bytes()).and_then(|_| o.flush());
142    }
143
144    pub fn log(&self, level: Level, message: impl Into<String>) {
145        self.send(&PluginMessage::Log { level, message: message.into() });
146    }
147    pub fn error(&self, message: impl Into<String>) {
148        self.log(Level::Error, message)
149    }
150    pub fn warn(&self, message: impl Into<String>) {
151        self.log(Level::Warn, message)
152    }
153    pub fn info(&self, message: impl Into<String>) {
154        self.log(Level::Info, message)
155    }
156    pub fn debug(&self, message: impl Into<String>) {
157        self.log(Level::Debug, message)
158    }
159
160    /// The plugin's health, shown in the interface until the next.
161    pub fn status(&self, state: State, message: impl Into<String>) {
162        self.send(&PluginMessage::Status { state, message: message.into() });
163    }
164
165    /// How the plugin's work is going (see [`Metrics`]): queue, timing,
166    /// the services it talks to. At most every few seconds.
167    pub fn metrics(&self, m: &Metrics) {
168        self.send(&PluginMessage::Metrics(m.clone()));
169    }
170
171    /// What became of a concluded call.
172    pub fn call_result(&self, path: &str, outcome: Outcome, message: impl Into<String>, url: impl Into<String>) {
173        self.send(&PluginMessage::CallResult { path: path.to_string(), outcome, message: message.into(), url: url.into(), took_ms: None });
174    }
175
176    /// The same, and how long the work on it took (an upload's last try):
177    /// the recorder tells waiting from uploading by it. [`crate::CallQueue`]
178    /// says it for you.
179    pub fn call_result_took(&self, path: &str, outcome: Outcome, message: impl Into<String>, url: impl Into<String>, took: std::time::Duration) {
180        let took_ms = Some(took.as_millis() as u64);
181        self.send(&PluginMessage::CallResult { path: path.to_string(), outcome, message: message.into(), url: url.into(), took_ms });
182    }
183}
184
185/// The answer to an [`Action`]: send it once, with [`Reply::ok`] or
186/// [`Reply::fail`]. It can be moved to another thread.
187pub struct Reply {
188    host: Host,
189    request: u64,
190    sent: bool,
191}
192
193impl Reply {
194    pub fn ok(mut self, message: impl Into<String>) {
195        self.send(true, message.into());
196    }
197    pub fn fail(mut self, message: impl Into<String>) {
198        self.send(false, message.into());
199    }
200    fn send(&mut self, ok: bool, message: String) {
201        self.sent = true;
202        self.host.send(&PluginMessage::ActionResult { request: self.request, ok, message });
203    }
204}
205
206impl Drop for Reply {
207    fn drop(&mut self) {
208        if !self.sent {
209            self.send(false, "The plugin didn't answer".into());
210        }
211    }
212}
213
214/// The manifest with the settings schemas filled in.
215pub fn describe<P: Plugin>() -> Manifest {
216    let mut m = P::manifest();
217    m.api = API_VERSION;
218    let c = schema::schema_for::<P::Config>();
219    m.config = (!schema::is_empty(&c)).then_some(c);
220    let s = schema::schema_for::<P::SystemConfig>();
221    m.system_config = (!schema::is_empty(&s)).then_some(s);
222    m
223}
224
225/// Run the plugin: `--describe` prints its manifest; otherwise it talks to the
226/// recorder on stdin/stdout until told to stop. Call from `main`.
227pub fn run<P: Plugin>() {
228    let arg = std::env::args().nth(1);
229    match arg.as_deref() {
230        Some("--describe") => {
231            println!("{}", serde_json::to_string_pretty(&describe::<P>()).unwrap_or_default());
232            return;
233        }
234        Some("--version" | "-V") => {
235            println!("{}", P::manifest().version);
236            return;
237        }
238        Some(a) => {
239            eprintln!("unknown argument {a} (a plugin takes --describe or --version; the recorder runs it with none)");
240            std::process::exit(2);
241        }
242        None => {}
243    }
244    let stdin = io::stdin();
245    if stdin.is_terminal() {
246        let m = P::manifest();
247        eprintln!(
248            "{} {} is a Trunk Recorder Pro plugin: the recorder runs it and talks to it over stdin/stdout.\n\
249             `--describe` prints its manifest. Try it with `trunk-pro plugin run <this binary>`,\n\
250             or paste protocol lines here (a hello first).",
251            m.name, m.version
252        );
253    }
254    let code = serve::<P>(stdin.lock(), Host { out: Arc::new(Mutex::new(Box::new(io::stdout()))) });
255    std::process::exit(code);
256}
257
258/// The message loop over `input`; the process's exit status.
259pub fn serve<P: Plugin>(input: impl BufRead, host: Host) -> i32 {
260    let mut plugin: Option<P> = None;
261    let mut grace = std::time::Duration::from_secs(10);
262    for line in input.lines() {
263        let Ok(line) = line else { break };
264        if line.trim().is_empty() {
265            continue;
266        }
267        let msg: HostMessage = match serde_json::from_str(&line) {
268            Ok(m) => m,
269            Err(e) => {
270                host.warn(format!("unreadable message from the recorder ({e}): {line}"));
271                continue;
272            }
273        };
274        let Some(p) = plugin.as_mut() else {
275            let HostMessage::Hello(hello) = msg else {
276                host.warn("a message before hello, ignored");
277                continue;
278            };
279            match start::<P>(host.clone(), hello) {
280                Ok(p) => {
281                    plugin = Some(p);
282                    host.send(&PluginMessage::Ready);
283                }
284                Err(e) => {
285                    host.status(State::Error, e.clone());
286                    host.error(e);
287                    return EXIT_CONFIG;
288                }
289            }
290            continue;
291        };
292        match msg {
293            HostMessage::Hello(_) => host.warn("a second hello, ignored"),
294            HostMessage::CallStart(c) => p.call_start(c),
295            HostMessage::CallEnd(c) => p.call_end(c),
296            HostMessage::CallConcluded(c) => p.call_concluded(c),
297            HostMessage::Unit(u) => p.unit(u),
298            HostMessage::Audio(a) => p.audio(a),
299            HostMessage::Status(s) => p.status(s),
300            HostMessage::TonePattern(t) => p.tone_pattern(t),
301            HostMessage::Action(a) => {
302                let reply = Reply { host: host.clone(), request: a.request, sent: false };
303                p.action(a, reply)
304            }
305            HostMessage::Settings(h) => match setup::<P>(h).and_then(|s| p.settings(s)) {
306                Ok(()) => host.status(State::Ok, ""),
307                Err(e) => host.status(State::Error, format!("new settings not taken: {e}")),
308            },
309            HostMessage::Shutdown(s) => {
310                grace = std::time::Duration::from_secs_f64(s.grace_s.max(0.0));
311                break;
312            }
313            HostMessage::Unknown => {}
314        }
315    }
316    if let Some(p) = plugin.as_mut() {
317        p.shutdown(grace);
318    }
319    0
320}
321
322fn start<P: Plugin>(host: Host, hello: Hello) -> Result<P, String> {
323    P::start(host, setup::<P>(hello)?)
324}
325
326/// What `hello` sets the plugin up with.
327fn setup<P: Plugin>(hello: Hello) -> Result<Setup<P::Config, P::SystemConfig>, String> {
328    if hello.api != 0 && hello.api < API_VERSION {
329        return Err(format!("this plugin needs a newer recorder (plugin API {API_VERSION}, the recorder has {})", hello.api));
330    }
331    let config: P::Config = parse(hello.config.clone(), "settings")?;
332    let mut systems = Vec::new();
333    for s in hello.systems {
334        let settings = match s.settings {
335            Value::Null => parse::<P::Config>(hello.config.clone(), "settings")?,
336            v => parse::<P::Config>(v, &format!("settings for {}", s.short_name))?,
337        };
338        let config = match s.config {
339            Value::Null => None,
340            v => Some(parse::<P::SystemConfig>(v, &format!("settings for {}", s.short_name))?),
341        };
342        systems.push(System { index: s.index, short_name: s.short_name, kind: s.kind, name: s.name, fcc: s.fcc, fcc_call_sign: s.fcc_call_sign, fcc_name: s.fcc_name, sites: s.sites, config, settings });
343    }
344    Ok(Setup { config, systems, host: hello.host, capture_dir: hello.capture_dir, data_dir: hello.data_dir, audio_formats: hello.audio_formats })
345}
346
347/// Settings left out entirely count as `{}`, so `#[serde(default)]` applies.
348fn parse<T: DeserializeOwned>(v: Value, what: &str) -> Result<T, String> {
349    let v = if v.is_null() { Value::Object(Default::default()) } else { v };
350    serde_json::from_value(v).map_err(|e| format!("bad {what}: {e}"))
351}
352
353/// A manifest with the id, version, description, homepage, repository,
354/// authors and license from Cargo.toml; the name is the id until you set one:
355///
356/// ```ignore
357/// fn manifest() -> Manifest {
358///     Manifest { name: "OpenMHz".into(), subscribe: vec![topic::CALL_CONCLUDED.into()], ..trunk_recorder_plugin::manifest!() }
359/// }
360/// ```
361#[macro_export]
362macro_rules! manifest {
363    () => {{
364        let authors: Vec<String> = env!("CARGO_PKG_AUTHORS").split(':').filter(|a| !a.is_empty()).map(String::from).collect();
365        $crate::Manifest {
366            id: env!("CARGO_PKG_NAME").to_string(),
367            name: env!("CARGO_PKG_NAME").to_string(),
368            version: env!("CARGO_PKG_VERSION").to_string(),
369            description: env!("CARGO_PKG_DESCRIPTION").to_string(),
370            api: $crate::API_VERSION,
371            homepage: env!("CARGO_PKG_HOMEPAGE").to_string(),
372            repository: env!("CARGO_PKG_REPOSITORY").to_string(),
373            authors,
374            license: env!("CARGO_PKG_LICENSE").to_string(),
375            ..Default::default()
376        }
377    }};
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::testing;
384    use serde_json::json;
385
386    #[derive(serde::Deserialize, schemars::JsonSchema, Default)]
387    #[serde(default)]
388    struct Config {
389        server: String,
390        retries: u32,
391    }
392
393    /// Says each system's server.
394    struct Servers;
395
396    impl Plugin for Servers {
397        type Config = Config;
398        type SystemConfig = NoConfig;
399        fn manifest() -> Manifest {
400            Manifest { id: "servers".into(), ..Default::default() }
401        }
402        fn start(host: Host, setup: Setup<Config, NoConfig>) -> Result<Self, String> {
403            for s in &setup.systems {
404                host.info(format!("{} {} {}", s.short_name, s.settings.server, s.settings.retries));
405            }
406            Ok(Servers)
407        }
408    }
409
410    /// A system's settings are the plugin's with what it overrides.
411    #[test]
412    fn a_system_can_override_the_plugins_settings() {
413        let dir = testing::temp_dir("sdk");
414        let mut h = testing::hello(&dir, json!({ "server": "https://a", "retries": 3 }));
415        h.systems.push(SystemInfo { index: 1, short_name: "sys2".into(), kind: "p25".into(), settings: json!({ "server": "https://b", "retries": 3 }), ..Default::default() });
416        let out = testing::run::<Servers>([HostMessage::Hello(h)]);
417        assert!(out.ready());
418        let said: Vec<String> = out.logs().into_iter().map(|(_, t)| t).collect();
419        assert_eq!(said, ["sys1 https://a 3", "sys2 https://b 3"]);
420    }
421}