Krolyxon
Commit c98214a

Branches

Refs →

budctlcratesbudctl-clisrcoutput.rs

565 lines · 16.8 KB · Rust

1//! Printing results, as text for people or as JSON (`--json`) for scripts and status bars.
2
3use std::io::{self, Write};
4
5use budctl_core::PairedDevice;
6use budctl_core::devices::Device;
7use budctl_core::status::{
8 BassBand, Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange,
9 TouchControls,
10};
11use budctl_core::{Error, Found};
12use serde_json::Value;
13
14/// Why a command did not finish: budctl failed, or stdout did.
15pub enum Failure {
16 Budctl(Error),
17 Io(io::Error),
18}
19
20impl From<Error> for Failure {
21 fn from(error: Error) -> Self {
22 Failure::Budctl(error)
23 }
24}
25
26impl From<io::Error> for Failure {
27 fn from(error: io::Error) -> Self {
28 Failure::Io(error)
29 }
30}
31
32impl From<serde_json::Error> for Failure {
33 fn from(error: serde_json::Error) -> Self {
34 Failure::Io(io::Error::from(error))
35 }
36}
37
38/// Print `body` inside the JSON envelope every result shares: `schema`, plus `device` and
39/// `address` when the result is about one pair of buds.
40fn json(out: &mut impl Write, target: Option<(&PairedDevice, &dyn Device)>, body: Value) -> Result<(), Failure> {
41 let mut envelope = serde_json::Map::new();
42 envelope.insert("schema".to_owned(), JSON_SCHEMA.into());
43 if let Some((device, model)) = target {
44 envelope.insert("device".to_owned(), model.name().into());
45 envelope.insert("address".to_owned(), device.address.to_string().into());
46 }
47 if let Value::Object(fields) = body {
48 envelope.extend(fields);
49 }
50 serde_json::to_writer(&mut *out, &envelope)?;
51 writeln!(out)?;
52 Ok(())
53}
54
55pub fn list(out: &mut impl Write, found: &[Found], as_json: bool) -> Result<(), Failure> {
56 if as_json {
57 let devices: Vec<Value> = found
58 .iter()
59 .map(|f| {
60 serde_json::json!({
61 "address": f.device.address,
62 "name": f.device.name,
63 "alias": f.device.alias,
64 "connected": f.device.connected,
65 // The model budctl recognized, or null.
66 "model": f.model.map(|m| m.name()),
67 })
68 })
69 .collect();
70 return json(out, None, serde_json::json!({ "devices": devices }));
71 }
72
73 if found.is_empty() {
74 writeln!(out, "No paired Bluetooth devices.")?;
75 return Ok(());
76 }
77 for f in found {
78 let state = if f.device.connected {
79 "connected"
80 } else {
81 "not connected"
82 };
83 let model = match f.model {
84 Some(model) => format!("supported: {}", model.name()),
85 None => "not supported".to_owned(),
86 };
87 writeln!(out, "{} {} ({state}, {model})", f.device.address, f.device.alias)?;
88 }
89 Ok(())
90}
91
92fn charge(charge: Option<Charge>) -> String {
93 match charge {
94 Some(Charge { level, charging: true }) => format!("{level}% (charging)"),
95 Some(Charge { level, charging: false }) => format!("{level}%"),
96 None => "—".to_owned(),
97 }
98}
99
100fn header(out: &mut impl Write, device: &PairedDevice, model: &dyn Device) -> io::Result<()> {
101 writeln!(out, "{} ({})", model.name(), device.address)
102}
103
104fn warnings(out: &mut impl Write, warnings: &[String]) -> io::Result<()> {
105 for warning in warnings {
106 writeln!(out, " warning: {warning}")?;
107 }
108 Ok(())
109}
110
111pub fn status(
112 out: &mut impl Write,
113 device: &PairedDevice,
114 model: &dyn Device,
115 status: &Status,
116 as_json: bool,
117) -> Result<(), Failure> {
118 if as_json {
119 return json(out, Some((device, model)), serde_json::to_value(status)?);
120 }
121
122 header(out, device, model)?;
123 let battery = &status.battery;
124 let mut rows: Vec<(&str, String)> = vec![(
125 "Battery",
126 format!(
127 "L {} R {} case {}",
128 charge(battery.left),
129 charge(battery.right),
130 charge(battery.case)
131 ),
132 )];
133 if let Some(noise) = &status.noise_control {
134 let value = match &noise.level {
135 Some(level) => format!("{} ({level})", noise.mode),
136 None => noise.mode.clone(),
137 };
138 rows.push(("Noise control", value));
139 }
140 if let Some(eq) = &status.eq {
141 rows.push(("EQ", eq.clone()));
142 }
143 if let Some(bass) = status.bass_level {
144 rows.push(("Bass level", bass.to_string()));
145 }
146 for switch in &status.switches {
147 rows.push((switch.label, if switch.on { "on" } else { "off" }.to_owned()));
148 }
149 if let Some(codec) = &status.codec {
150 rows.push(("Codec", codec.clone()));
151 }
152 if let Some(firmware) = &status.firmware {
153 rows.push(("Firmware", firmware.clone()));
154 }
155 // Labels are as wide as the longest one, so values line up whatever the device names.
156 let width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0);
157 for (label, value) in &rows {
158 writeln!(out, " {label:<width$} {value}")?;
159 }
160 warnings(out, &status.warnings)?;
161 Ok(())
162}
163
164pub fn touch(
165 out: &mut impl Write,
166 device: &PairedDevice,
167 model: &dyn Device,
168 touch: &TouchControls,
169 as_json: bool,
170) -> Result<(), Failure> {
171 if as_json {
172 return json(out, Some((device, model)), serde_json::to_value(touch)?);
173 }
174
175 header(out, device, model)?;
176 if touch.gestures.is_empty() {
177 writeln!(out, " (no gestures reported)")?;
178 }
179 for g in &touch.gestures {
180 match g.group {
181 Some(group) => writeln!(out, " {:<6} {:<12} {} [group {group}]", g.side, g.gesture, g.action)?,
182 None => writeln!(out, " {:<6} {:<12} {}", g.side, g.gesture, g.action)?,
183 }
184 }
185 if let Some(cycle) = &touch.hold_cycle {
186 writeln!(out, " Hold cycles through: {}", cycle.join(" → "))?;
187 }
188 warnings(out, &touch.warnings)?;
189 Ok(())
190}
191
192/// The hold cycle after a change, as the buds report it on reading back. JSON uses the same
193/// `hold_cycle` list as `touch`.
194pub fn hold_cycle(
195 out: &mut impl Write,
196 device: &PairedDevice,
197 model: &dyn Device,
198 now: &[String],
199 as_json: bool,
200) -> Result<(), Failure> {
201 if as_json {
202 return json(out, Some((device, model)), serde_json::json!({ "hold_cycle": now }));
203 }
204 header(out, device, model)?;
205 writeln!(out, " Hold cycles through: {}", now.join(" → "))?;
206 Ok(())
207}
208
209/// A touch-control change: what changed (or would change, on a dry run) and the write.
210pub fn touch_change(
211 out: &mut impl Write,
212 device: &PairedDevice,
213 model: &dyn Device,
214 change: &TouchChange,
215 as_json: bool,
216) -> Result<(), Failure> {
217 if as_json {
218 return json(out, Some((device, model)), serde_json::to_value(change)?);
219 }
220 header(out, device, model)?;
221 if change.changes.is_empty() {
222 writeln!(out, " Nothing to change: the buds already have that action.")?;
223 return Ok(());
224 }
225 for c in &change.changes {
226 writeln!(out, " {:<6} {:<12} {} → {}", c.side, c.gesture, c.from, c.to)?;
227 }
228 if let (false, Some(write)) = (change.sent, &change.write) {
229 writeln!(out, " Dry run, nothing sent. The write would be:")?;
230 writeln!(
231 out,
232 " 0x{:04X} {}",
233 write.command,
234 budctl_core::debug::hex(&write.payload)
235 )?;
236 }
237 Ok(())
238}
239
240/// The noise-control mode after a change, as the buds report it on reading back.
241pub fn noise_control(
242 out: &mut impl Write,
243 device: &PairedDevice,
244 model: &dyn Device,
245 now: &NoiseControl,
246 as_json: bool,
247) -> Result<(), Failure> {
248 if as_json {
249 return json(
250 out,
251 Some((device, model)),
252 serde_json::json!({ "noise_control": serde_json::to_value(now)? }),
253 );
254 }
255 header(out, device, model)?;
256 match &now.level {
257 Some(level) => writeln!(out, " Noise control {} ({level})", now.mode)?,
258 None => writeln!(out, " Noise control {}", now.mode)?,
259 }
260 Ok(())
261}
262
263/// One switch after a change, as the buds report it on reading back. JSON uses the same
264/// `switches` map as `status`.
265pub fn switch(
266 out: &mut impl Write,
267 device: &PairedDevice,
268 model: &dyn Device,
269 now: &Switch,
270 as_json: bool,
271) -> Result<(), Failure> {
272 if as_json {
273 return json(
274 out,
275 Some((device, model)),
276 serde_json::json!({ "switches": { now.key: now.on } }),
277 );
278 }
279 header(out, device, model)?;
280 writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?;
281 Ok(())
282}
283
284/// Every EQ preset, with the one in use marked `*`.
285pub fn eq_presets(
286 out: &mut impl Write,
287 device: &PairedDevice,
288 model: &dyn Device,
289 eq: &EqPresets,
290 as_json: bool,
291) -> Result<(), Failure> {
292 if as_json {
293 return json(out, Some((device, model)), serde_json::to_value(eq)?);
294 }
295 header(out, device, model)?;
296 let current = eq.current.as_deref();
297 for preset in eq.presets.iter().filter(|p| !p.custom) {
298 let mark = if current == Some(preset.name.as_str()) {
299 "*"
300 } else {
301 " "
302 };
303 writeln!(out, " {mark} {}", preset.name)?;
304 }
305 let custom: Vec<_> = eq.presets.iter().filter(|p| p.custom).collect();
306 if custom.is_empty() {
307 writeln!(out, " Custom presets: none")?;
308 } else {
309 writeln!(out, " Custom presets:")?;
310 }
311 for preset in custom {
312 let name = format!("custom-{}", preset.id);
313 let mark = if current == Some(name.as_str()) { "*" } else { " " };
314 let bands: Vec<String> = preset
315 .bands
316 .iter()
317 .map(|b| format!("{} Hz {:+}", b.hz, b.gain))
318 .collect();
319 writeln!(out, " {mark} {name} \"{}\": {}", preset.name, bands.join(", "))?;
320 }
321 warnings(out, &eq.warnings)?;
322 Ok(())
323}
324
325/// One custom preset after it was saved and selected, as the buds list it on reading back.
326pub fn custom_eq(
327 out: &mut impl Write,
328 device: &PairedDevice,
329 model: &dyn Device,
330 now: &EqPreset,
331 as_json: bool,
332) -> Result<(), Failure> {
333 if as_json {
334 return json(
335 out,
336 Some((device, model)),
337 serde_json::json!({ "eq": format!("custom-{}", now.id), "preset": now }),
338 );
339 }
340 header(out, device, model)?;
341 let bands: Vec<String> = now.bands.iter().map(|b| format!("{} Hz {:+}", b.hz, b.gain)).collect();
342 writeln!(out, " EQ custom-{} \"{}\": {}", now.id, now.name, bands.join(", "))?;
343 Ok(())
344}
345
346/// A custom preset just created, as the buds list it.
347pub fn created_eq(
348 out: &mut impl Write,
349 device: &PairedDevice,
350 model: &dyn Device,
351 created: &EqPreset,
352 as_json: bool,
353) -> Result<(), Failure> {
354 if as_json {
355 return json(
356 out,
357 Some((device, model)),
358 serde_json::json!({ "created": format!("custom-{}", created.id), "preset": created }),
359 );
360 }
361 header(out, device, model)?;
362 let bands: Vec<String> = created
363 .bands
364 .iter()
365 .map(|b| format!("{} Hz {:+}", b.hz, b.gain))
366 .collect();
367 writeln!(
368 out,
369 " New preset custom-{} \"{}\": {}",
370 created.id,
371 created.name,
372 bands.join(", ")
373 )?;
374 Ok(())
375}
376
377/// The custom presets left after deleting one, as the buds list them.
378pub fn deleted_eq(
379 out: &mut impl Write,
380 device: &PairedDevice,
381 model: &dyn Device,
382 deleted: &str,
383 left: &[EqPreset],
384 as_json: bool,
385) -> Result<(), Failure> {
386 if as_json {
387 return json(
388 out,
389 Some((device, model)),
390 serde_json::json!({ "deleted": deleted, "presets": left }),
391 );
392 }
393 header(out, device, model)?;
394 writeln!(out, " Deleted {deleted}")?;
395 for preset in left {
396 writeln!(out, " Left custom-{} \"{}\"", preset.id, preset.name)?;
397 }
398 Ok(())
399}
400
401/// A custom preset just renamed, as the buds list it.
402pub fn renamed_eq(
403 out: &mut impl Write,
404 device: &PairedDevice,
405 model: &dyn Device,
406 now: &EqPreset,
407 as_json: bool,
408) -> Result<(), Failure> {
409 if as_json {
410 return json(
411 out,
412 Some((device, model)),
413 serde_json::json!({ "renamed": format!("custom-{}", now.id), "preset": now }),
414 );
415 }
416 header(out, device, model)?;
417 writeln!(out, " Renamed custom-{} \"{}\"", now.id, now.name)?;
418 Ok(())
419}
420
421/// The EQ preset after a change, as the buds report it on reading back.
422pub fn eq(
423 out: &mut impl Write,
424 device: &PairedDevice,
425 model: &dyn Device,
426 now: &str,
427 as_json: bool,
428) -> Result<(), Failure> {
429 if as_json {
430 return json(out, Some((device, model)), serde_json::json!({ "eq": now }));
431 }
432 header(out, device, model)?;
433 writeln!(out, " EQ {now}")?;
434 Ok(())
435}
436
437/// After `bass <band> <level>`: the buds acked; they cannot report the level back.
438pub fn bass_band(
439 out: &mut impl Write,
440 device: &PairedDevice,
441 model: &dyn Device,
442 band: BassBand,
443 level: i8,
444 as_json: bool,
445) -> Result<(), Failure> {
446 if as_json {
447 let body = serde_json::json!({ "bass": { "band": band, "level": level } });
448 return json(out, Some((device, model)), body);
449 }
450 header(out, device, model)?;
451 writeln!(
452 out,
453 " Bass, {} frequencies {level:+} (accepted; the buds cannot report it back)",
454 band.name()
455 )?;
456 Ok(())
457}
458
459/// The devices dual connection keeps: name, address, and whether each is connected.
460pub fn dual_devices(
461 out: &mut impl Write,
462 device: &PairedDevice,
463 model: &dyn Device,
464 devices: &[DualDevice],
465 as_json: bool,
466) -> Result<(), Failure> {
467 if as_json {
468 return json(out, Some((device, model)), serde_json::json!({ "devices": devices }));
469 }
470 header(out, device, model)?;
471 if devices.is_empty() {
472 writeln!(out, " No devices listed.")?;
473 }
474 let width = devices.iter().map(|d| d.name.chars().count()).max().unwrap_or(0);
475 for d in devices {
476 let mut state = vec![if d.connected { "connected" } else { "not connected" }];
477 for (on, what) in [
478 (d.this_computer, "this computer"),
479 (d.main_audio, "main audio"),
480 (d.playing, "playing"),
481 ] {
482 if on {
483 state.push(what);
484 }
485 }
486 writeln!(out, " {:<width$} {} {}", d.name, d.address, state.join(", "))?;
487 }
488 Ok(())
489}
490
491/// After `find start` or `find stop`: the buds acked; there is nothing to read back.
492pub fn find(
493 out: &mut impl Write,
494 device: &PairedDevice,
495 model: &dyn Device,
496 ring: bool,
497 as_json: bool,
498) -> Result<(), Failure> {
499 if as_json {
500 return json(out, Some((device, model)), serde_json::json!({ "ringing": ring }));
501 }
502 header(out, device, model)?;
503 if ring {
504 writeln!(out, " Ringing. Stop with: budctl find stop")?;
505 } else {
506 writeln!(out, " Stopped ringing.")?;
507 }
508 Ok(())
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use budctl_core::devices::REGISTRY;
515
516 #[test]
517 fn dual_devices_line_up_and_keep_their_json_names() {
518 let buds = PairedDevice {
519 address: "88:0E:85:46:FE:5C".parse().unwrap(),
520 name: "realme Buds T310".to_owned(),
521 alias: "realme Buds T310".to_owned(),
522 connected: true,
523 uuids: Vec::new(),
524 };
525 let model = *REGISTRY.first().unwrap();
526 let devices = [
527 DualDevice {
528 address: "2B:1A:00:AA:BB:CC".parse().unwrap(),
529 name: "arch".to_owned(),
530 connected: true,
531 this_computer: true,
532 main_audio: true,
533 playing: false,
534 },
535 DualDevice {
536 address: "11:22:33:44:55:66".parse().unwrap(),
537 name: "Pixel 8".to_owned(),
538 connected: false,
539 this_computer: false,
540 main_audio: false,
541 playing: false,
542 },
543 ];
544 let mut text = Vec::new();
545 assert!(dual_devices(&mut text, &buds, model, &devices, false).is_ok());
546 assert_eq!(
547 String::from_utf8(text).unwrap(),
548 "realme Buds T310 (88:0E:85:46:FE:5C)\n\
549 \x20 arch 2B:1A:00:AA:BB:CC connected, this computer, main audio\n\
550 \x20 Pixel 8 11:22:33:44:55:66 not connected\n"
551 );
552
553 let mut json_out = Vec::new();
554 assert!(dual_devices(&mut json_out, &buds, model, &devices, true).is_ok());
555 let value: Value = serde_json::from_slice(&json_out).unwrap();
556 assert_eq!(value["schema"], 1);
557 assert_eq!(value["devices"][0]["this_computer"], true);
558 assert_eq!(value["devices"][1]["address"], "11:22:33:44:55:66");
559 assert_eq!(value["devices"][1]["connected"], false);
560
561 let mut empty = Vec::new();
562 assert!(dual_devices(&mut empty, &buds, model, &[], false).is_ok());
563 assert!(String::from_utf8(empty).unwrap().ends_with(" No devices listed.\n"));
564 }
565}