Krolyxon
Commit 65fed26

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

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