Krolyxon
Commit a345a10

Branches

Refs →

budctlcratesbudctl-clisrcmain.rs

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