atagen

headroom

public
Star0
Watch0
b4f9722db37afb8ec0881327edf1a085c821d6a9
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git
crates/headroom-cli/src/main.rsRust442 lines12.9 KB
1
//! `headroom` cli — connects to the daemon over its uds; `daemon` runs core in-process.
2
3
#![forbid(unsafe_code)]
4
5
#[cfg(feature = "tui")]
6
mod tui;
7
8
use std::path::PathBuf;
9
use std::process::ExitCode;
10
11
use clap::{Parser, Subcommand, ValueEnum};
12
use headroom_client::{Client, ClientError, Route, Topic};
13
14
// debug-only: arms core's audio-thread `assert_no_alloc` blocks; no-op in release.
15
#[cfg(debug_assertions)]
16
#[global_allocator]
17
static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
18
19
/// headroom cli.
20
#[derive(Debug, Parser)]
21
#[command(version, about, long_about = None)]
22
struct Cli {
23
    /// override the daemon control socket path.
24
    #[arg(long, global = true)]
25
    socket: Option<PathBuf>,
26
27
    #[command(subcommand)]
28
    cmd: Cmd,
29
}
30
31
#[derive(Debug, Subcommand)]
32
enum Cmd {
33
    /// run the daemon in the foreground.
34
    Daemon {
35
        /// extra `*.toml` profile dir (repeatable). precedes shipped/user
36
        /// profiles; first dir is watched for live edits.
37
        #[arg(long = "profile-dir", value_name = "DIR")]
38
        profile_dir: Vec<PathBuf>,
39
    },
40
41
    /// show daemon status.
42
    Status,
43
44
    /// profile management.
45
    #[command(subcommand)]
46
    Profile(ProfileCmd),
47
48
    /// routing rules and per-stream decisions.
49
    #[command(subcommand)]
50
    Route(RouteCmd),
51
52
    /// per-app level control (layer a).
53
    #[command(subcommand)]
54
    PerApp(PerAppCmd),
55
56
    /// get a setting value from the active profile.
57
    Get {
58
        /// dotted setting key.
59
        key: String,
60
    },
61
62
    /// set a setting override (or clear with `--clear`). overrides persist
63
    /// across profile switches; `reset` clears all.
64
    Set {
65
        /// dotted setting key.
66
        key: String,
67
        /// new value, json-encoded. omit with `--clear`.
68
        // allow_hyphen_values so negatives aren't parsed as flags.
69
        #[arg(allow_hyphen_values = true)]
70
        value: Option<String>,
71
        /// clear this key's override instead of setting one.
72
        #[arg(long, conflicts_with = "value")]
73
        clear: bool,
74
    },
75
76
    /// toggle the global bypass kill switch.
77
    Bypass {
78
        /// `on` or `off`.
79
        #[arg(value_enum)]
80
        state: BypassState,
81
    },
82
83
    /// clear all setting overrides. leaves route/per-app/bypass intact.
84
    Reset,
85
86
    /// reload profile files from disk.
87
    Reload,
88
89
    /// live monitor. defaults to a tui; `--json` is the line-delimited stream.
90
    Monitor {
91
        /// topics to subscribe to (comma-separated). `--json` only; the tui
92
        /// subscribes to all.
93
        #[arg(value_delimiter = ',', default_value = "meters")]
94
        topics: Vec<MonitorTopic>,
95
96
        /// emit one json event per line instead of the tui.
97
        #[arg(long)]
98
        json: bool,
99
    },
100
}
101
102
#[derive(Debug, Clone, Copy, ValueEnum)]
103
enum MonitorTopic {
104
    Meters,
105
    Profile,
106
    Routing,
107
    Daemon,
108
}
109
110
impl From<MonitorTopic> for Topic {
111
    fn from(t: MonitorTopic) -> Self {
112
        match t {
113
            MonitorTopic::Meters => Topic::Meters,
114
            MonitorTopic::Profile => Topic::Profile,
115
            MonitorTopic::Routing => Topic::Routing,
116
            MonitorTopic::Daemon => Topic::Daemon,
117
        }
118
    }
119
}
120
121
#[derive(Debug, Subcommand)]
122
enum ProfileCmd {
123
    /// list known profiles.
124
    List,
125
    /// activate the named profile.
126
    Use {
127
        /// profile name.
128
        name: String,
129
    },
130
    /// show a profile in full.
131
    Show {
132
        /// profile name (defaults to the active profile).
133
        name: Option<String>,
134
    },
135
}
136
137
#[derive(Debug, Subcommand)]
138
enum RouteCmd {
139
    /// list routing rules and current per-stream decisions.
140
    List,
141
    /// add or replace a routing rule for an app.
142
    Set {
143
        /// app identifier (e.g. `application.process.binary`).
144
        app: String,
145
        /// where to route.
146
        #[arg(value_enum)]
147
        to: RouteArg,
148
    },
149
    /// remove an app's user routing rule.
150
    Unset {
151
        /// app identifier.
152
        app: String,
153
    },
154
    /// reroute a specific live stream by node id.
155
    Stream {
156
        /// pipewire node id.
157
        node_id: u32,
158
        /// where to route.
159
        #[arg(value_enum)]
160
        to: RouteArg,
161
    },
162
}
163
164
#[derive(Debug, Subcommand)]
165
enum PerAppCmd {
166
    /// show per-app layer a state for managed streams.
167
    Status {
168
        /// emit the snapshot list as json instead of a table.
169
        #[arg(long)]
170
        json: bool,
171
    },
172
    /// enable the layer a master switch (persisted).
173
    On,
174
    /// disable the layer a master switch (persisted).
175
    Off,
176
    /// enable or disable layer a for a specific app (persisted).
177
    Set {
178
        /// app identifier (process_binary or application_name).
179
        app: String,
180
        /// `on` or `off`.
181
        #[arg(value_enum)]
182
        state: BypassState,
183
    },
184
    /// clear a managed stream's deference lock.
185
    Reset {
186
        /// pipewire node id of the managed stream.
187
        node_id: u32,
188
    },
189
}
190
191
#[derive(Debug, Clone, Copy, ValueEnum)]
192
enum BypassState {
193
    On,
194
    Off,
195
}
196
197
#[derive(Debug, Clone, Copy, ValueEnum)]
198
enum RouteArg {
199
    Processed,
200
    Bypass,
201
}
202
203
impl From<RouteArg> for Route {
204
    fn from(r: RouteArg) -> Self {
205
        match r {
206
            RouteArg::Processed => Route::Processed,
207
            RouteArg::Bypass => Route::Bypass,
208
        }
209
    }
210
}
211
212
fn init_tracing() {
213
    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
214
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
215
    tracing_subscriber::fmt()
216
        .with_env_filter(env_filter)
217
        .with_target(false)
218
        .compact()
219
        .init();
220
}
221
222
fn run() -> Result<(), CliError> {
223
    let cli = Cli::parse();
224
225
    // tui owns the terminal; keep `tracing` off it.
226
    let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
227
    if !tui_mode {
228
        init_tracing();
229
    }
230
231
    match cli.cmd {
232
        Cmd::Daemon { profile_dir } => {
233
            headroom_core::run_with_profile_dirs(profile_dir)
234
                .map_err(|e| CliError::Daemon(e.to_string()))?;
235
            Ok(())
236
        }
237
        Cmd::Monitor { json: false, .. } => {
238
            #[cfg(feature = "tui")]
239
            {
240
                // connect on the main thread so initial rpcs run before raw mode.
241
                let client = match cli.socket.as_deref() {
242
                    Some(p) => Client::connect_at(p)?,
243
                    None => Client::connect()?,
244
                };
245
                tui::run(client).map_err(CliError::Tui)
246
            }
247
            // no `tui` feature: point at the json stream, not an opaque failure.
248
            #[cfg(not(feature = "tui"))]
249
            {
250
                let _ = cli.socket.as_deref();
251
                Err(CliError::Other(
252
                    "this build was compiled without the TUI (`tui` feature disabled); \
253
                     rerun `headroom monitor --json` for the line-delimited event stream"
254
                        .into(),
255
                ))
256
            }
257
        }
258
        cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
259
    }
260
}
261
262
fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
263
where
264
    F: FnOnce(&mut Client) -> Result<(), CliError>,
265
{
266
    let mut client = match socket {
267
        Some(p) => Client::connect_at(p)?,
268
        None => Client::connect()?,
269
    };
270
    f(&mut client)
271
}
272
273
fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
274
    match cmd {
275
        Cmd::Daemon { .. } => unreachable!("handled in `run`"),
276
277
        Cmd::Status => {
278
            let status = client.status()?;
279
            println!("{}", serde_json::to_string_pretty(&status)?);
280
        }
281
282
        Cmd::Profile(ProfileCmd::List) => {
283
            let profiles = client.profile_list()?;
284
            for p in profiles {
285
                let marker = if p.active { '*' } else { ' ' };
286
                println!("{marker} {:<16} {}", p.name, p.description);
287
            }
288
        }
289
        Cmd::Profile(ProfileCmd::Use { name }) => {
290
            let active = client.profile_use(&name)?;
291
            println!("active profile: {active}");
292
        }
293
        Cmd::Profile(ProfileCmd::Show { name }) => {
294
            let body = client.profile_show(name.as_deref())?;
295
            println!("{}", serde_json::to_string_pretty(&body)?);
296
        }
297
298
        Cmd::Route(RouteCmd::List) => {
299
            let list = client.route_list()?;
300
            println!("{}", serde_json::to_string_pretty(&list)?);
301
        }
302
        Cmd::Route(RouteCmd::Set { app, to }) => {
303
            client.route_set(&app, to.into())?;
304
        }
305
        Cmd::Route(RouteCmd::Unset { app }) => {
306
            client.route_unset(&app)?;
307
        }
308
        Cmd::Route(RouteCmd::Stream { node_id, to }) => {
309
            client.route_stream(node_id, to.into())?;
310
        }
311
312
        Cmd::PerApp(PerAppCmd::Status { json }) => {
313
            let list = client.layer_a_list()?;
314
            if json {
315
                println!("{}", serde_json::to_string_pretty(&list)?);
316
            } else if list.is_empty() {
317
                println!("no streams under Layer A management");
318
            } else {
319
                println!(
320
                    "{:<8} {:<24} {:>10} {:>9} {:>9}",
321
                    "node", "app", "reduction", "ceiling", "deferred"
322
                );
323
                for s in &list {
324
                    let app = if s.app.len() > 24 {
325
                        format!("{}…", &s.app[..23])
326
                    } else {
327
                        s.app.clone()
328
                    };
329
                    let ceiling = s
330
                        .user_ceiling_lin
331
                        .map(|c| format!("{c:.2}"))
332
                        .unwrap_or_else(|| "—".to_string());
333
                    println!(
334
                        "{:<8} {:<24} {:>8.1}dB {:>9} {:>9}",
335
                        s.node_id, app, s.reduction_db, ceiling, s.deferred
336
                    );
337
                }
338
            }
339
        }
340
        Cmd::PerApp(PerAppCmd::On) => {
341
            client.per_app_master(true)?;
342
        }
343
        Cmd::PerApp(PerAppCmd::Off) => {
344
            client.per_app_master(false)?;
345
        }
346
        Cmd::PerApp(PerAppCmd::Set { app, state }) => {
347
            client.per_app_set(&app, matches!(state, BypassState::On))?;
348
        }
349
        Cmd::PerApp(PerAppCmd::Reset { node_id }) => {
350
            client.layer_a_reset(node_id)?;
351
        }
352
353
        Cmd::Get { key } => {
354
            let v = client.setting_get(&key)?;
355
            println!("{}", serde_json::to_string(&v)?);
356
        }
357
        Cmd::Set { key, value, clear } => {
358
            if clear {
359
                let existed = client.setting_clear(&key)?;
360
                if existed {
361
                    println!("cleared override for '{key}'");
362
                } else {
363
                    println!("no override set for '{key}'");
364
                }
365
            } else {
366
                let value = value.ok_or_else(|| {
367
                    CliError::Other("provide a value, or use --clear to remove the override".into())
368
                })?;
369
                let parsed: serde_json::Value = serde_json::from_str(&value)
370
                    .map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
371
                client.setting_set(&key, parsed)?;
372
            }
373
        }
374
        Cmd::Bypass { state } => {
375
            client.bypass_set(matches!(state, BypassState::On))?;
376
        }
377
        Cmd::Reset => {
378
            let cleared = client.setting_reset()?;
379
            println!("cleared {cleared} setting override(s)");
380
        }
381
        Cmd::Reload => {
382
            let reloaded = client.profile_reload()?;
383
            println!("reloaded: {reloaded:?}");
384
        }
385
        Cmd::Monitor { topics, json } => {
386
            if json {
387
                let pw_topics: Vec<Topic> =
388
                    topics.iter().copied().map(Topic::from).collect();
389
                client.subscribe(&pw_topics)?;
390
                loop {
391
                    let ev = client.next_event()?;
392
                    println!(
393
                        "{} {}/{} {}",
394
                        chrono_like_now(),
395
                        ev.topic,
396
                        ev.event,
397
                        serde_json::to_string(&ev.data)?,
398
                    );
399
                }
400
            } else {
401
                unreachable!("TUI monitor is dispatched before `with_client`")
402
            }
403
        }
404
    }
405
    Ok(())
406
}
407
408
#[derive(Debug, thiserror::Error)]
409
enum CliError {
410
    #[error("client: {0}")]
411
    Client(#[from] ClientError),
412
413
    #[error("daemon: {0}")]
414
    Daemon(String),
415
416
    #[error("json: {0}")]
417
    Json(#[from] serde_json::Error),
418
419
    #[cfg(feature = "tui")]
420
    #[error("tui: {0}")]
421
    Tui(tui::TuiError),
422
423
    #[error("{0}")]
424
    Other(String),
425
}
426
427
/// timestamp label for monitor output; `SystemTime` to avoid chrono.
428
fn chrono_like_now() -> String {
429
    use std::time::{SystemTime, UNIX_EPOCH};
430
    let t = SystemTime::now()
431
        .duration_since(UNIX_EPOCH)
432
        .unwrap_or_default();
433
    format!("{}.{:03}", t.as_secs(), t.subsec_millis())
434
}
435
436
fn main() -> ExitCode {
437
    if let Err(e) = run() {
438
        eprintln!("headroom: {e}");
439
        return ExitCode::from(1);
440
    }
441
    ExitCode::SUCCESS
442
}