Krolyxon
Commit f5d5748

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

396 lines · 12.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 #[command(args_conflicts_with_subcommands = true)]
73 Eq {
74 #[command(subcommand)]
75 action: Option<EqCommand>,
76 preset: Option<String>,
77 /// Bands to change, as `<hz>=<gain>`. Custom presets only.
78 #[arg(requires = "preset", value_parser = parse_band, allow_negative_numbers = true)]
79 bands: Vec<EqBand>,
80 },
81 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
82 Set {
83 /// The switch's name, as `status --json` lists it.
84 switch: String,
85 #[arg(value_enum)]
86 state: OnOff,
87 },
88 /// Set the bass level, -5 to +5. The buds cannot report it back, so only their ack is checked.
89 Bass {
90 /// Checked here, before connecting; core checks it again.
91 #[arg(allow_negative_numbers = true, value_parser = clap::value_parser!(i8).range(-5..=5))]
92 level: i8,
93 },
94 /// Ring the buds so you can find them (loud: take them out of your ears first).
95 Find {
96 #[arg(value_enum)]
97 action: FindAction,
98 /// Confirm that the buds are out of your ears. Needed to start ringing.
99 #[arg(long)]
100 yes: bool,
101 },
102 /// Turn game mode (low latency) on or off, then read it back.
103 Game {
104 #[arg(value_enum)]
105 state: OnOff,
106 },
107}
108
109#[derive(Clone, Copy, ValueEnum)]
110enum FindAction {
111 Start,
112 Stop,
113}
114
115#[derive(Subcommand)]
116enum EqCommand {
117 /// Create a custom preset, flat, with the bands of the buds' custom presets, then read it back.
118 New {
119 /// Its name, 1 to 16 bytes.
120 #[arg(value_parser = parse_name)]
121 name: String,
122 },
123}
124
125#[derive(Subcommand)]
126enum TouchCommand {
127 /// Set the noise-control modes a hold steps through (both buds), then read it back.
128 ///
129 /// At least two modes. The buds step through them in their own order.
130 Cycle {
131 #[arg(value_enum, required = true, num_args = 2..=3, value_name = "MODE")]
132 modes: Vec<CycleMode>,
133 },
134}
135
136#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
137enum CycleMode {
138 Off,
139 Transparency,
140 /// Noise cancelling.
141 Anc,
142}
143
144impl CycleMode {
145 fn name(self) -> &'static str {
146 match self {
147 CycleMode::Off => "off",
148 CycleMode::Transparency => "transparency",
149 CycleMode::Anc => "anc",
150 }
151 }
152}
153
154#[derive(Clone, Copy, ValueEnum)]
155enum Side {
156 Left,
157 Right,
158 /// The left and the right bud.
159 Both,
160}
161
162impl Side {
163 fn name(self) -> &'static str {
164 match self {
165 Side::Left => "left",
166 Side::Right => "right",
167 Side::Both => "both",
168 }
169 }
170}
171
172#[derive(Clone, Copy, ValueEnum)]
173enum OnOff {
174 On,
175 Off,
176}
177
178#[derive(Clone, Copy, ValueEnum)]
179enum AncArg {
180 Off,
181 Transparency,
182 /// Noise cancelling at the deep level (the buds use the same value for both).
183 On,
184 Light,
185 Medium,
186 Deep,
187}
188
189impl From<AncArg> for NoiseMode {
190 fn from(arg: AncArg) -> Self {
191 match arg {
192 AncArg::Off => NoiseMode::Off,
193 AncArg::On | AncArg::Deep => NoiseMode::Anc(AncLevel::Deep),
194 AncArg::Light => NoiseMode::Anc(AncLevel::Light),
195 AncArg::Medium => NoiseMode::Anc(AncLevel::Medium),
196 AncArg::Transparency => NoiseMode::Transparency,
197 }
198 }
199}
200
201fn main() -> ExitCode {
202 let cli = match Cli::try_parse() {
203 Ok(cli) => cli,
204 Err(error) => {
205 // Help and version are not errors; everything else is a usage error (exit 1).
206 let code = if error.use_stderr() { 1 } else { 0 };
207 // Nothing useful can be done if printing the usage message fails.
208 let _ = error.print();
209 return ExitCode::from(code);
210 }
211 };
212 if cli.debug {
213 budctl_core::debug::enable();
214 }
215
216 let mut stdout = io::stdout().lock();
217 let result = run(&cli, &mut stdout).and_then(|()| stdout.flush().map_err(output::Failure::Io));
218 match result {
219 Ok(()) => ExitCode::SUCCESS,
220 // `budctl status | head -1` is fine.
221 Err(output::Failure::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
222 Err(output::Failure::Io(e)) => {
223 eprintln!("budctl: cannot write output: {e}");
224 ExitCode::from(1)
225 }
226 Err(output::Failure::Budctl(e)) => {
227 eprintln!("budctl: {e}");
228 ExitCode::from(exit_code(&e))
229 }
230 }
231}
232
233fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
234 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
235 if let Command::Find {
236 action: FindAction::Start,
237 yes: false,
238 } = cli.command
239 {
240 return Err(Error::Usage(
241 "this rings the buds loudly; take them out of your ears, then run `budctl find start --yes`".to_owned(),
242 )
243 .into());
244 }
245 let found = budctl_core::scan()?;
246 if let Command::List = cli.command {
247 return output::list(out, &found, cli.json);
248 }
249
250 let (device, model) = budctl_core::select(found, cli.device)?;
251 let mut session = budctl_core::connect(&device, model)?;
252 match cli.command {
253 Command::List => Ok(()),
254 Command::Status => {
255 let status = session.status()?;
256 output::status(out, &device, model, &status, cli.json)
257 }
258 Command::Touch {
259 cycle: Some(TouchCommand::Cycle { ref modes }),
260 ..
261 } => {
262 let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
263 let now = session.set_hold_cycle(&names)?;
264 output::hold_cycle(out, &device, model, &now, cli.json)
265 }
266 Command::Touch {
267 side: Some(side),
268 gesture: Some(ref gesture),
269 action: Some(ref action),
270 dry_run,
271 ..
272 } => {
273 let change = session.set_touch(side.name(), gesture, action, dry_run)?;
274 output::touch_change(out, &device, model, &change, cli.json)
275 }
276 Command::Touch { .. } => {
277 let touch = session.touch_controls()?;
278 output::touch(out, &device, model, &touch, cli.json)
279 }
280 Command::Anc { mode } => {
281 let now = session.set_noise_control(mode.into())?;
282 output::noise_control(out, &device, model, &now, cli.json)
283 }
284 Command::Eq {
285 action: Some(EqCommand::New { ref name }),
286 ..
287 } => {
288 let created = session.create_custom_eq(name)?;
289 output::created_eq(out, &device, model, &created, cli.json)
290 }
291 Command::Eq { preset: None, .. } => {
292 let presets = session.eq_presets()?;
293 output::eq_presets(out, &device, model, &presets, cli.json)
294 }
295 Command::Eq {
296 preset: Some(ref preset),
297 ref bands,
298 ..
299 } if !bands.is_empty() => {
300 let now = session.set_custom_eq(preset, bands)?;
301 output::custom_eq(out, &device, model, &now, cli.json)
302 }
303 Command::Eq {
304 preset: Some(ref preset),
305 ..
306 } => {
307 let now = session.set_eq(preset)?;
308 output::eq(out, &device, model, &now, cli.json)
309 }
310 Command::Set { ref switch, state } => {
311 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
312 output::switch(out, &device, model, &now, cli.json)
313 }
314 Command::Bass { level } => {
315 session.set_bass_level(level)?;
316 output::bass_level(out, &device, model, level, cli.json)
317 }
318 Command::Find { action, .. } => {
319 let ring = matches!(action, FindAction::Start);
320 session.find(ring)?;
321 output::find(out, &device, model, ring, cli.json)
322 }
323 Command::Game { state } => {
324 let now = session.set_switch("game", matches!(state, OnOff::On))?;
325 output::switch(out, &device, model, &now, cli.json)
326 }
327 }
328}
329
330/// A preset name, checked before connecting: not empty, no spaces around it, no control
331/// characters. The buds' own limits are checked once their list is read.
332fn parse_name(text: &str) -> Result<String, String> {
333 if text.is_empty() || text.trim() != text {
334 return Err("a preset name cannot be empty, or start or end with a space".to_owned());
335 }
336 if text.chars().any(char::is_control) {
337 return Err("a preset name cannot hold control characters".to_owned());
338 }
339 Ok(text.to_owned())
340}
341
342/// `50=+3` → 50 Hz at +3 dB.
343fn parse_band(text: &str) -> Result<EqBand, String> {
344 let (hz, gain) = text
345 .split_once('=')
346 .ok_or_else(|| format!("{text} is not <hz>=<gain>, e.g. 50=+3"))?;
347 Ok(EqBand {
348 hz: hz.parse().map_err(|_| format!("{hz} is not a frequency in Hz"))?,
349 gain: gain.parse().map_err(|_| format!("{gain} is not a gain in whole dB"))?,
350 })
351}
352
353/// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out.
354fn exit_code(error: &Error) -> u8 {
355 match error.kind() {
356 Kind::Usage => 1,
357 Kind::NotFound => 2,
358 Kind::Busy => 3,
359 Kind::Failed => 4,
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn parse(args: &[&str]) -> Command {
368 Cli::try_parse_from(std::iter::once("budctl").chain(args.iter().copied()))
369 .unwrap()
370 .command
371 }
372
373 #[test]
374 fn eq_takes_a_preset_bands_or_new() {
375 assert!(matches!(
376 parse(&["eq"]),
377 Command::Eq {
378 action: None,
379 preset: None,
380 ..
381 }
382 ));
383 assert!(matches!(
384 parse(&["eq", "custom-4", "50=+3", "16000=-2"]),
385 Command::Eq { action: None, preset: Some(p), bands } if p == "custom-4" && bands.len() == 2
386 ));
387 assert!(matches!(
388 parse(&["eq", "deep-bass"]),
389 Command::Eq { action: None, preset: Some(p), .. } if p == "deep-bass"
390 ));
391 assert!(matches!(
392 parse(&["eq", "new", "Gym mix"]),
393 Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix"
394 ));
395 }
396}