Krolyxon
Commit 7cea7cb

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

477 lines · 15.4 KB · Rust

1//! `budctl`: control wireless earbuds from Linux.
2//!
3//! A thin layer over budctl-core: parse the command line, run one command, print the result,
4//! turn errors into the exit codes listed in CLAUDE.md.
5
6mod output;
7
8use std::io::{self, Write};
9use std::process::ExitCode;
10
11use budctl_core::bt::BdAddr;
12use budctl_core::status::{AncLevel, BassBand, EqBand, NoiseMode};
13use budctl_core::{Error, Kind};
14use clap::{Parser, Subcommand, ValueEnum};
15
16/// Control wireless earbuds from Linux, without the vendor app.
17#[derive(Parser)]
18#[command(name = "budctl", version)]
19struct Cli {
20 /// The buds to use, when more than one supported pair is paired. Default: "device" in
21 /// ~/.config/budctl/config.json.
22 #[arg(long, global = true, value_name = "MAC")]
23 device: Option<BdAddr>,
24
25 /// Machine-readable output (JSON with "schema": 1).
26 #[arg(long, global = true)]
27 json: bool,
28
29 /// Hex-dump every frame sent and received on stderr.
30 #[arg(long, global = true)]
31 debug: bool,
32
33 #[command(subcommand)]
34 command: Command,
35}
36
37#[derive(Subcommand)]
38enum Command {
39 /// Paired devices and whether budctl recognizes them.
40 List,
41 /// Battery, noise control, EQ and switches.
42 Status,
43 /// Touch controls (gesture table) of each bud, or give one gesture a new action.
44 ///
45 /// Gestures: double-tap, triple-tap, hold. Actions: none, play-pause, next, previous,
46 /// voice-assistant, volume-up, volume-down, noise-cycle, game-mode (not every gesture takes
47 /// every action). `budctl touch cycle …` sets what a hold steps through.
48 #[command(args_conflicts_with_subcommands = true)]
49 Touch {
50 #[command(subcommand)]
51 cycle: Option<TouchCommand>,
52 /// The bud to change: left, right, or both.
53 #[arg(value_enum, requires = "gesture")]
54 side: Option<Side>,
55 /// The gesture to change.
56 #[arg(requires = "action")]
57 gesture: Option<String>,
58 /// Its new action.
59 action: Option<String>,
60 /// Read the table and show the write, but send nothing.
61 #[arg(long, requires = "side")]
62 dry_run: bool,
63 },
64 /// Set noise control, then read it back.
65 Anc {
66 #[arg(value_enum)]
67 mode: AncArg,
68 },
69 /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass, custom-<id>) and read it back.
70 ///
71 /// For a custom preset, `<hz>=<gain>` pairs change those bands' gains (dB), then save and
72 /// select it: `budctl eq custom-4 50=+3 16000=-2`. `budctl eq new <name>` creates a preset,
73 /// `budctl eq rename custom-<id> <name>` renames one, `budctl eq delete custom-<id>` deletes one.
74 #[command(args_conflicts_with_subcommands = true)]
75 Eq {
76 #[command(subcommand)]
77 action: Option<EqCommand>,
78 preset: Option<String>,
79 /// Bands to change, as `<hz>=<gain>`. Custom presets only.
80 #[arg(requires = "preset", value_parser = parse_band, allow_negative_numbers = true)]
81 bands: Vec<EqBand>,
82 },
83 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
84 Set {
85 /// The switch's name, as `status --json` lists it.
86 switch: String,
87 #[arg(value_enum)]
88 state: OnOff,
89 },
90 /// Set the level of one band of bass boost (low, medium, high), -5 to +5; bass boost must be on
91 /// (`set bass-boost on`). The buds cannot report it back, so only their ack is checked.
92 Bass {
93 #[arg(value_enum)]
94 band: BandArg,
95 /// Checked here, before connecting; core checks it again.
96 #[arg(allow_negative_numbers = true, value_parser = clap::value_parser!(i8).range(-5..=5))]
97 level: i8,
98 },
99 /// Ring the buds so you can find them (loud: take them out of your ears first).
100 Find {
101 #[arg(value_enum)]
102 action: FindAction,
103 /// Confirm that the buds are out of your ears. Needed to start ringing.
104 #[arg(long)]
105 yes: bool,
106 },
107 /// Turn game mode (low latency) on or off, then read it back.
108 Game {
109 #[arg(value_enum)]
110 state: OnOff,
111 },
112 /// The phones and computers the buds keep for dual connection, and which are connected.
113 Dual,
114}
115
116#[derive(Clone, Copy, ValueEnum)]
117enum FindAction {
118 Start,
119 Stop,
120}
121
122#[derive(Subcommand)]
123enum EqCommand {
124 /// Create a custom preset, flat, with the bands of the buds' custom presets, then read it back.
125 New {
126 /// Its name, 1 to 16 bytes.
127 #[arg(value_parser = parse_name)]
128 name: String,
129 },
130 /// Give a custom preset a new name, then read it back. The buds save it, which also selects
131 /// it.
132 Rename {
133 /// The preset, as `budctl eq` lists it: custom-<id>.
134 preset: String,
135 /// Its new name, 1 to 16 bytes.
136 #[arg(value_parser = parse_name)]
137 name: String,
138 },
139 /// Delete a custom preset, then read the list back. Its bands are lost.
140 Delete {
141 /// The preset, as `budctl eq` lists it: custom-<id>.
142 preset: String,
143 },
144}
145
146#[derive(Subcommand)]
147enum TouchCommand {
148 /// Set the noise-control modes a hold steps through (both buds), then read it back.
149 ///
150 /// At least two modes. The buds step through them in their own order.
151 Cycle {
152 #[arg(value_enum, required = true, num_args = 2..=3, value_name = "MODE")]
153 modes: Vec<CycleMode>,
154 },
155}
156
157#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
158enum CycleMode {
159 Off,
160 Transparency,
161 /// Noise cancelling.
162 Anc,
163}
164
165impl CycleMode {
166 fn name(self) -> &'static str {
167 match self {
168 CycleMode::Off => "off",
169 CycleMode::Transparency => "transparency",
170 CycleMode::Anc => "anc",
171 }
172 }
173}
174
175#[derive(Clone, Copy, ValueEnum)]
176enum Side {
177 Left,
178 Right,
179 /// The left and the right bud.
180 Both,
181}
182
183impl Side {
184 fn name(self) -> &'static str {
185 match self {
186 Side::Left => "left",
187 Side::Right => "right",
188 Side::Both => "both",
189 }
190 }
191}
192
193#[derive(Clone, Copy, ValueEnum)]
194enum BandArg {
195 Low,
196 Medium,
197 High,
198}
199
200impl From<BandArg> for BassBand {
201 fn from(arg: BandArg) -> Self {
202 match arg {
203 BandArg::Low => BassBand::Low,
204 BandArg::Medium => BassBand::Medium,
205 BandArg::High => BassBand::High,
206 }
207 }
208}
209
210#[derive(Clone, Copy, ValueEnum)]
211enum OnOff {
212 On,
213 Off,
214}
215
216#[derive(Clone, Copy, ValueEnum)]
217enum AncArg {
218 Off,
219 Transparency,
220 /// Noise cancelling at the deep level (the buds use the same value for both).
221 On,
222 Light,
223 Medium,
224 Deep,
225}
226
227impl From<AncArg> for NoiseMode {
228 fn from(arg: AncArg) -> Self {
229 match arg {
230 AncArg::Off => NoiseMode::Off,
231 AncArg::On | AncArg::Deep => NoiseMode::Anc(AncLevel::Deep),
232 AncArg::Light => NoiseMode::Anc(AncLevel::Light),
233 AncArg::Medium => NoiseMode::Anc(AncLevel::Medium),
234 AncArg::Transparency => NoiseMode::Transparency,
235 }
236 }
237}
238
239fn main() -> ExitCode {
240 let cli = match Cli::try_parse() {
241 Ok(cli) => cli,
242 Err(error) => {
243 // Help and version are not errors; everything else is a usage error (exit 1).
244 let code = if error.use_stderr() { 1 } else { 0 };
245 // Nothing useful can be done if printing the usage message fails.
246 let _ = error.print();
247 return ExitCode::from(code);
248 }
249 };
250 if cli.debug {
251 budctl_core::debug::enable();
252 }
253
254 let mut stdout = io::stdout().lock();
255 let result = run(&cli, &mut stdout).and_then(|()| stdout.flush().map_err(output::Failure::Io));
256 match result {
257 Ok(()) => ExitCode::SUCCESS,
258 // `budctl status | head -1` is fine.
259 Err(output::Failure::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
260 Err(output::Failure::Io(e)) => {
261 eprintln!("budctl: cannot write output: {e}");
262 ExitCode::from(1)
263 }
264 Err(output::Failure::Budctl(e)) => {
265 eprintln!("budctl: {e}");
266 ExitCode::from(exit_code(&e))
267 }
268 }
269}
270
271/// The config's default device. A config that cannot be used is reported and left out, so it
272/// never stops a command.
273fn default_device() -> Option<BdAddr> {
274 let (config, problem) = budctl_core::config::load();
275 if let Some(problem) = problem {
276 eprintln!("budctl: {problem}; ignoring it");
277 }
278 config.device().unwrap_or_default()
279}
280
281fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
282 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
283 if let Command::Find {
284 action: FindAction::Start,
285 yes: false,
286 } = cli.command
287 {
288 return Err(Error::Usage(
289 "this rings the buds loudly; take them out of your ears, then run `budctl find start --yes`".to_owned(),
290 )
291 .into());
292 }
293 let found = budctl_core::scan()?;
294 if let Command::List = cli.command {
295 return output::list(out, &found, cli.json);
296 }
297
298 let (device, model) = budctl_core::select(found, cli.device.or_else(default_device))?;
299 let mut session = budctl_core::connect(&device, model)?;
300 match cli.command {
301 Command::List => Ok(()),
302 Command::Status => {
303 let status = session.status()?;
304 output::status(out, &device, model, &status, cli.json)
305 }
306 Command::Touch {
307 cycle: Some(TouchCommand::Cycle { ref modes }),
308 ..
309 } => {
310 let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
311 let now = session.set_hold_cycle(&names)?;
312 output::hold_cycle(out, &device, model, &now, cli.json)
313 }
314 Command::Touch {
315 side: Some(side),
316 gesture: Some(ref gesture),
317 action: Some(ref action),
318 dry_run,
319 ..
320 } => {
321 let change = session.set_touch(side.name(), gesture, action, dry_run)?;
322 output::touch_change(out, &device, model, &change, cli.json)
323 }
324 Command::Touch { .. } => {
325 let touch = session.touch_controls()?;
326 output::touch(out, &device, model, &touch, cli.json)
327 }
328 Command::Anc { mode } => {
329 let now = session.set_noise_control(mode.into())?;
330 output::noise_control(out, &device, model, &now, cli.json)
331 }
332 Command::Eq {
333 action: Some(EqCommand::New { ref name }),
334 ..
335 } => {
336 let created = session.create_custom_eq(name)?;
337 output::created_eq(out, &device, model, &created, cli.json)
338 }
339 Command::Eq {
340 action: Some(EqCommand::Rename { ref preset, ref name }),
341 ..
342 } => {
343 let now = session.rename_custom_eq(preset, name)?;
344 output::renamed_eq(out, &device, model, &now, cli.json)
345 }
346 Command::Eq {
347 action: Some(EqCommand::Delete { ref preset }),
348 ..
349 } => {
350 let left = session.delete_custom_eq(preset)?;
351 output::deleted_eq(out, &device, model, preset, &left, cli.json)
352 }
353 Command::Eq { preset: None, .. } => {
354 let presets = session.eq_presets()?;
355 output::eq_presets(out, &device, model, &presets, cli.json)
356 }
357 Command::Eq {
358 preset: Some(ref preset),
359 ref bands,
360 ..
361 } if !bands.is_empty() => {
362 let now = session.set_custom_eq(preset, bands)?;
363 output::custom_eq(out, &device, model, &now, cli.json)
364 }
365 Command::Eq {
366 preset: Some(ref preset),
367 ..
368 } => {
369 let now = session.set_eq(preset)?;
370 output::eq(out, &device, model, &now, cli.json)
371 }
372 Command::Set { ref switch, state } => {
373 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
374 output::switch(out, &device, model, &now, cli.json)
375 }
376 Command::Bass { band, level } => {
377 session.set_bass_band(band.into(), level)?;
378 output::bass_band(out, &device, model, band.into(), level, cli.json)
379 }
380 Command::Find { action, .. } => {
381 let ring = matches!(action, FindAction::Start);
382 session.find(ring)?;
383 output::find(out, &device, model, ring, cli.json)
384 }
385 Command::Game { state } => {
386 let now = session.set_switch("game", matches!(state, OnOff::On))?;
387 output::switch(out, &device, model, &now, cli.json)
388 }
389 Command::Dual => {
390 let devices = session.dual_devices()?;
391 output::dual_devices(out, &device, model, &devices, cli.json)
392 }
393 }
394}
395
396/// A preset name, checked before connecting: not empty, no spaces around it, no control
397/// characters. The buds' own limits are checked once their list is read.
398fn parse_name(text: &str) -> Result<String, String> {
399 if text.is_empty() || text.trim() != text {
400 return Err("a preset name cannot be empty, or start or end with a space".to_owned());
401 }
402 if text.chars().any(char::is_control) {
403 return Err("a preset name cannot hold control characters".to_owned());
404 }
405 Ok(text.to_owned())
406}
407
408/// `50=+3` → 50 Hz at +3 dB.
409fn parse_band(text: &str) -> Result<EqBand, String> {
410 let (hz, gain) = text
411 .split_once('=')
412 .ok_or_else(|| format!("{text} is not <hz>=<gain>, e.g. 50=+3"))?;
413 Ok(EqBand {
414 hz: hz.parse().map_err(|_| format!("{hz} is not a frequency in Hz"))?,
415 gain: gain.parse().map_err(|_| format!("{gain} is not a gain in whole dB"))?,
416 })
417}
418
419/// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out.
420fn exit_code(error: &Error) -> u8 {
421 match error.kind() {
422 Kind::Usage => 1,
423 Kind::NotFound => 2,
424 Kind::Busy => 3,
425 Kind::Failed => 4,
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn parse(args: &[&str]) -> Command {
434 Cli::try_parse_from(std::iter::once("budctl").chain(args.iter().copied()))
435 .unwrap()
436 .command
437 }
438
439 #[test]
440 fn dual_takes_nothing() {
441 assert!(matches!(parse(&["dual"]), Command::Dual));
442 assert!(Cli::try_parse_from(["budctl", "dual", "off"]).is_err());
443 }
444
445 #[test]
446 fn eq_takes_a_preset_bands_or_new() {
447 assert!(matches!(
448 parse(&["eq"]),
449 Command::Eq {
450 action: None,
451 preset: None,
452 ..
453 }
454 ));
455 assert!(matches!(
456 parse(&["eq", "custom-4", "50=+3", "16000=-2"]),
457 Command::Eq { action: None, preset: Some(p), bands } if p == "custom-4" && bands.len() == 2
458 ));
459 assert!(matches!(
460 parse(&["eq", "deep-bass"]),
461 Command::Eq { action: None, preset: Some(p), .. } if p == "deep-bass"
462 ));
463 assert!(matches!(
464 parse(&["eq", "new", "Gym mix"]),
465 Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix"
466 ));
467 assert!(matches!(
468 parse(&["eq", "delete", "custom-5"]),
469 Command::Eq { action: Some(EqCommand::Delete { preset }), .. } if preset == "custom-5"
470 ));
471 assert!(matches!(
472 parse(&["eq", "rename", "custom-5", "Gym"]),
473 Command::Eq { action: Some(EqCommand::Rename { preset, name }), .. }
474 if preset == "custom-5" && name == "Gym"
475 ));
476 }
477}