Krolyxon
Commit 5e24422

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

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