Krolyxon
Commit 7a29fd2

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

201 lines · 6.1 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, 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.
43 Touch,
44 /// Set noise control, then read it back.
45 Anc {
46 #[arg(value_enum)]
47 mode: AncArg,
48 },
49 /// Select an EQ preset (pure-raw, deep-bass, pure-vocals, pure-bass), then read it back.
50 Eq { preset: String },
51 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
52 Set {
53 /// The switch's name, as `status --json` lists it.
54 switch: String,
55 #[arg(value_enum)]
56 state: OnOff,
57 },
58 /// Ring the buds so you can find them (loud: take them out of your ears first).
59 Find {
60 #[arg(value_enum)]
61 action: FindAction,
62 /// Confirm that the buds are out of your ears. Needed to start ringing.
63 #[arg(long)]
64 yes: bool,
65 },
66 /// Turn game mode (low latency) on or off, then read it back.
67 Game {
68 #[arg(value_enum)]
69 state: OnOff,
70 },
71}
72
73#[derive(Clone, Copy, ValueEnum)]
74enum FindAction {
75 Start,
76 Stop,
77}
78
79#[derive(Clone, Copy, ValueEnum)]
80enum OnOff {
81 On,
82 Off,
83}
84
85#[derive(Clone, Copy, ValueEnum)]
86enum AncArg {
87 Off,
88 Transparency,
89 /// Noise cancelling at the deep level (the buds use the same value for both).
90 On,
91 Light,
92 Medium,
93 Deep,
94}
95
96impl From<AncArg> for NoiseMode {
97 fn from(arg: AncArg) -> Self {
98 match arg {
99 AncArg::Off => NoiseMode::Off,
100 AncArg::On | AncArg::Deep => NoiseMode::Anc(AncLevel::Deep),
101 AncArg::Light => NoiseMode::Anc(AncLevel::Light),
102 AncArg::Medium => NoiseMode::Anc(AncLevel::Medium),
103 AncArg::Transparency => NoiseMode::Transparency,
104 }
105 }
106}
107
108fn main() -> ExitCode {
109 let cli = match Cli::try_parse() {
110 Ok(cli) => cli,
111 Err(error) => {
112 // Help and version are not errors; everything else is a usage error (exit 1).
113 let code = if error.use_stderr() { 1 } else { 0 };
114 // Nothing useful can be done if printing the usage message fails.
115 let _ = error.print();
116 return ExitCode::from(code);
117 }
118 };
119 if cli.debug {
120 budctl_core::debug::enable();
121 }
122
123 let mut stdout = io::stdout().lock();
124 let result = run(&cli, &mut stdout).and_then(|()| stdout.flush().map_err(output::Failure::Io));
125 match result {
126 Ok(()) => ExitCode::SUCCESS,
127 // `budctl status | head -1` is fine.
128 Err(output::Failure::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
129 Err(output::Failure::Io(e)) => {
130 eprintln!("budctl: cannot write output: {e}");
131 ExitCode::from(1)
132 }
133 Err(output::Failure::Budctl(e)) => {
134 eprintln!("budctl: {e}");
135 ExitCode::from(exit_code(&e))
136 }
137 }
138}
139
140fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
141 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
142 if let Command::Find {
143 action: FindAction::Start,
144 yes: false,
145 } = cli.command
146 {
147 return Err(Error::Usage(
148 "this rings the buds loudly; take them out of your ears, then run `budctl find start --yes`".to_owned(),
149 )
150 .into());
151 }
152 let found = budctl_core::scan()?;
153 if let Command::List = cli.command {
154 return output::list(out, &found, cli.json);
155 }
156
157 let (device, model) = budctl_core::select(found, cli.device)?;
158 let mut session = budctl_core::connect(&device, model)?;
159 match cli.command {
160 Command::List => Ok(()),
161 Command::Status => {
162 let status = session.status()?;
163 output::status(out, &device, model, &status, cli.json)
164 }
165 Command::Touch => {
166 let touch = session.touch_controls()?;
167 output::touch(out, &device, model, &touch, cli.json)
168 }
169 Command::Anc { mode } => {
170 let now = session.set_noise_control(mode.into())?;
171 output::noise_control(out, &device, model, &now, cli.json)
172 }
173 Command::Eq { ref preset } => {
174 let now = session.set_eq(preset)?;
175 output::eq(out, &device, model, &now, cli.json)
176 }
177 Command::Set { ref switch, state } => {
178 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
179 output::switch(out, &device, model, &now, cli.json)
180 }
181 Command::Find { action, .. } => {
182 let ring = matches!(action, FindAction::Start);
183 session.find(ring)?;
184 output::find(out, &device, model, ring, cli.json)
185 }
186 Command::Game { state } => {
187 let now = session.set_switch("game", matches!(state, OnOff::On))?;
188 output::switch(out, &device, model, &now, cli.json)
189 }
190 }
191}
192
193/// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out.
194fn exit_code(error: &Error) -> u8 {
195 match error.kind() {
196 Kind::Usage => 1,
197 Kind::NotFound => 2,
198 Kind::Busy => 3,
199 Kind::Failed => 4,
200 }
201}