Krolyxon
Commit 4c49f6c

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

306 lines · 9.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, 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 /// Select an EQ preset (pure-raw, deep-bass, pure-vocals, pure-bass), then read it back.
69 Eq { preset: String },
70 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
71 Set {
72 /// The switch's name, as `status --json` lists it.
73 switch: String,
74 #[arg(value_enum)]
75 state: OnOff,
76 },
77 /// Ring the buds so you can find them (loud: take them out of your ears first).
78 Find {
79 #[arg(value_enum)]
80 action: StartStop,
81 /// Confirm that the buds are out of your ears. Needed to start ringing.
82 #[arg(long)]
83 yes: bool,
84 },
85 /// Check how well each bud seals in your ear. Keep both buds in your ears; it takes about 10 s.
86 FitTest {
87 /// `stop` ends a test that is still running.
88 #[arg(value_enum, default_value = "start")]
89 action: StartStop,
90 },
91 /// Turn game mode (low latency) on or off, then read it back.
92 Game {
93 #[arg(value_enum)]
94 state: OnOff,
95 },
96}
97
98#[derive(Clone, Copy, ValueEnum)]
99enum StartStop {
100 Start,
101 Stop,
102}
103
104#[derive(Subcommand)]
105enum TouchCommand {
106 /// Set the noise-control modes a hold steps through (both buds), then read it back.
107 ///
108 /// At least two modes. The buds step through them in their own order.
109 Cycle {
110 #[arg(value_enum, required = true, num_args = 2..=3, value_name = "MODE")]
111 modes: Vec<CycleMode>,
112 },
113}
114
115#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
116enum CycleMode {
117 Off,
118 Transparency,
119 /// Noise cancelling.
120 Anc,
121}
122
123impl CycleMode {
124 fn name(self) -> &'static str {
125 match self {
126 CycleMode::Off => "off",
127 CycleMode::Transparency => "transparency",
128 CycleMode::Anc => "anc",
129 }
130 }
131}
132
133#[derive(Clone, Copy, ValueEnum)]
134enum Side {
135 Left,
136 Right,
137 /// The left and the right bud.
138 Both,
139}
140
141impl Side {
142 fn name(self) -> &'static str {
143 match self {
144 Side::Left => "left",
145 Side::Right => "right",
146 Side::Both => "both",
147 }
148 }
149}
150
151#[derive(Clone, Copy, ValueEnum)]
152enum OnOff {
153 On,
154 Off,
155}
156
157#[derive(Clone, Copy, ValueEnum)]
158enum AncArg {
159 Off,
160 Transparency,
161 /// Noise cancelling at the deep level (the buds use the same value for both).
162 On,
163 Light,
164 Medium,
165 Deep,
166}
167
168impl From<AncArg> for NoiseMode {
169 fn from(arg: AncArg) -> Self {
170 match arg {
171 AncArg::Off => NoiseMode::Off,
172 AncArg::On | AncArg::Deep => NoiseMode::Anc(AncLevel::Deep),
173 AncArg::Light => NoiseMode::Anc(AncLevel::Light),
174 AncArg::Medium => NoiseMode::Anc(AncLevel::Medium),
175 AncArg::Transparency => NoiseMode::Transparency,
176 }
177 }
178}
179
180fn main() -> ExitCode {
181 let cli = match Cli::try_parse() {
182 Ok(cli) => cli,
183 Err(error) => {
184 // Help and version are not errors; everything else is a usage error (exit 1).
185 let code = if error.use_stderr() { 1 } else { 0 };
186 // Nothing useful can be done if printing the usage message fails.
187 let _ = error.print();
188 return ExitCode::from(code);
189 }
190 };
191 if cli.debug {
192 budctl_core::debug::enable();
193 }
194
195 let mut stdout = io::stdout().lock();
196 let result = run(&cli, &mut stdout).and_then(|()| stdout.flush().map_err(output::Failure::Io));
197 match result {
198 Ok(()) => ExitCode::SUCCESS,
199 // `budctl status | head -1` is fine.
200 Err(output::Failure::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
201 Err(output::Failure::Io(e)) => {
202 eprintln!("budctl: cannot write output: {e}");
203 ExitCode::from(1)
204 }
205 Err(output::Failure::Budctl(e)) => {
206 eprintln!("budctl: {e}");
207 ExitCode::from(exit_code(&e))
208 }
209 }
210}
211
212fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
213 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
214 if let Command::Find {
215 action: StartStop::Start,
216 yes: false,
217 } = cli.command
218 {
219 return Err(Error::Usage(
220 "this rings the buds loudly; take them out of your ears, then run `budctl find start --yes`".to_owned(),
221 )
222 .into());
223 }
224 let found = budctl_core::scan()?;
225 if let Command::List = cli.command {
226 return output::list(out, &found, cli.json);
227 }
228
229 let (device, model) = budctl_core::select(found, cli.device)?;
230 let mut session = budctl_core::connect(&device, model)?;
231 match cli.command {
232 Command::List => Ok(()),
233 Command::Status => {
234 let status = session.status()?;
235 output::status(out, &device, model, &status, cli.json)
236 }
237 Command::Touch {
238 cycle: Some(TouchCommand::Cycle { ref modes }),
239 ..
240 } => {
241 let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
242 let now = session.set_hold_cycle(&names)?;
243 output::hold_cycle(out, &device, model, &now, cli.json)
244 }
245 Command::Touch {
246 side: Some(side),
247 gesture: Some(ref gesture),
248 action: Some(ref action),
249 dry_run,
250 ..
251 } => {
252 let change = session.set_touch(side.name(), gesture, action, dry_run)?;
253 output::touch_change(out, &device, model, &change, cli.json)
254 }
255 Command::Touch { .. } => {
256 let touch = session.touch_controls()?;
257 output::touch(out, &device, model, &touch, cli.json)
258 }
259 Command::Anc { mode } => {
260 let now = session.set_noise_control(mode.into())?;
261 output::noise_control(out, &device, model, &now, cli.json)
262 }
263 Command::Eq { ref preset } => {
264 let now = session.set_eq(preset)?;
265 output::eq(out, &device, model, &now, cli.json)
266 }
267 Command::Set { ref switch, state } => {
268 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
269 output::switch(out, &device, model, &now, cli.json)
270 }
271 Command::Find { action, .. } => {
272 let ring = matches!(action, StartStop::Start);
273 session.find(ring)?;
274 output::find(out, &device, model, ring, cli.json)
275 }
276 Command::FitTest {
277 action: StartStop::Start,
278 } => {
279 if !cli.json {
280 eprintln!("Running the fit test: keep both buds in your ears…");
281 }
282 let buds = session.fit_test()?;
283 output::fit_test(out, &device, model, &buds, cli.json)
284 }
285 Command::FitTest {
286 action: StartStop::Stop,
287 } => {
288 session.stop_fit_test()?;
289 output::fit_test_stopped(out, &device, model, cli.json)
290 }
291 Command::Game { state } => {
292 let now = session.set_switch("game", matches!(state, OnOff::On))?;
293 output::switch(out, &device, model, &now, cli.json)
294 }
295 }
296}
297
298/// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out.
299fn exit_code(error: &Error) -> u8 {
300 match error.kind() {
301 Kind::Usage => 1,
302 Kind::NotFound => 2,
303 Kind::Busy => 3,
304 Kind::Failed => 4,
305 }
306}