Krolyxon
Commit 832675a

Branches

Refs →

budctlcratesbudctl-clisrcoutput.rs

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