Krolyxon
Commit 54dede1

Branches

Refs →

budctlcratesbudctl-clisrcoutput.rs

356 lines · 10.6 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::bluez::PairedDevice;
6use budctl_core::devices::Device;
7use budctl_core::status::{Charge, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls};
8use budctl_core::{Error, Found};
9use serde_json::Value;
10
11/// Why a command did not finish: budctl failed, or stdout did.
12pub enum Failure {
13 Budctl(Error),
14 Io(io::Error),
15}
16
17impl From<Error> for Failure {
18 fn from(error: Error) -> Self {
19 Failure::Budctl(error)
20 }
21}
22
23impl From<io::Error> for Failure {
24 fn from(error: io::Error) -> Self {
25 Failure::Io(error)
26 }
27}
28
29impl From<serde_json::Error> for Failure {
30 fn from(error: serde_json::Error) -> Self {
31 Failure::Io(io::Error::from(error))
32 }
33}
34
35/// Print `body` inside the JSON envelope every result shares: `schema`, plus `device` and
36/// `address` when the result is about one pair of buds.
37fn json(out: &mut impl Write, target: Option<(&PairedDevice, &dyn Device)>, body: Value) -> Result<(), Failure> {
38 let mut envelope = serde_json::Map::new();
39 envelope.insert("schema".to_owned(), JSON_SCHEMA.into());
40 if let Some((device, model)) = target {
41 envelope.insert("device".to_owned(), model.name().into());
42 envelope.insert("address".to_owned(), device.address.to_string().into());
43 }
44 if let Value::Object(fields) = body {
45 envelope.extend(fields);
46 }
47 serde_json::to_writer(&mut *out, &envelope)?;
48 writeln!(out)?;
49 Ok(())
50}
51
52pub fn list(out: &mut impl Write, found: &[Found], as_json: bool) -> Result<(), Failure> {
53 if as_json {
54 let devices: Vec<Value> = found
55 .iter()
56 .map(|f| {
57 serde_json::json!({
58 "address": f.device.address,
59 "name": f.device.name,
60 "alias": f.device.alias,
61 "connected": f.device.connected,
62 // The model budctl recognized, or null.
63 "model": f.model.map(|m| m.name()),
64 })
65 })
66 .collect();
67 return json(out, None, serde_json::json!({ "devices": devices }));
68 }
69
70 if found.is_empty() {
71 writeln!(out, "No paired Bluetooth devices.")?;
72 return Ok(());
73 }
74 for f in found {
75 let state = if f.device.connected {
76 "connected"
77 } else {
78 "not connected"
79 };
80 let model = match f.model {
81 Some(model) => format!("supported: {}", model.name()),
82 None => "not supported".to_owned(),
83 };
84 writeln!(out, "{} {} ({state}, {model})", f.device.address, f.device.alias)?;
85 }
86 Ok(())
87}
88
89fn charge(charge: Option<Charge>) -> String {
90 match charge {
91 Some(Charge { level, charging: true }) => format!("{level}% (charging)"),
92 Some(Charge { level, charging: false }) => format!("{level}%"),
93 None => "—".to_owned(),
94 }
95}
96
97fn header(out: &mut impl Write, device: &PairedDevice, model: &dyn Device) -> io::Result<()> {
98 writeln!(out, "{} ({})", model.name(), device.address)
99}
100
101fn warnings(out: &mut impl Write, warnings: &[String]) -> io::Result<()> {
102 for warning in warnings {
103 writeln!(out, " warning: {warning}")?;
104 }
105 Ok(())
106}
107
108pub fn status(
109 out: &mut impl Write,
110 device: &PairedDevice,
111 model: &dyn Device,
112 status: &Status,
113 as_json: bool,
114) -> Result<(), Failure> {
115 if as_json {
116 return json(out, Some((device, model)), serde_json::to_value(status)?);
117 }
118
119 header(out, device, model)?;
120 let battery = &status.battery;
121 let mut rows: Vec<(&str, String)> = vec![(
122 "Battery",
123 format!(
124 "L {} R {} case {}",
125 charge(battery.left),
126 charge(battery.right),
127 charge(battery.case)
128 ),
129 )];
130 if let Some(noise) = &status.noise_control {
131 let value = match &noise.level {
132 Some(level) => format!("{} ({level})", noise.mode),
133 None => noise.mode.clone(),
134 };
135 rows.push(("Noise control", value));
136 }
137 if let Some(eq) = &status.eq {
138 rows.push(("EQ", eq.clone()));
139 }
140 if let Some(bass) = status.bass_level {
141 rows.push(("Bass level", bass.to_string()));
142 }
143 for switch in &status.switches {
144 rows.push((switch.label, if switch.on { "on" } else { "off" }.to_owned()));
145 }
146 if let Some(codec) = &status.codec {
147 rows.push(("Codec", codec.clone()));
148 }
149 if let Some(firmware) = &status.firmware {
150 rows.push(("Firmware", firmware.clone()));
151 }
152 // Labels are as wide as the longest one, so values line up whatever the device names.
153 let width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0);
154 for (label, value) in &rows {
155 writeln!(out, " {label:<width$} {value}")?;
156 }
157 warnings(out, &status.warnings)?;
158 Ok(())
159}
160
161pub fn touch(
162 out: &mut impl Write,
163 device: &PairedDevice,
164 model: &dyn Device,
165 touch: &TouchControls,
166 as_json: bool,
167) -> Result<(), Failure> {
168 if as_json {
169 return json(out, Some((device, model)), serde_json::to_value(touch)?);
170 }
171
172 header(out, device, model)?;
173 if touch.gestures.is_empty() {
174 writeln!(out, " (no gestures reported)")?;
175 }
176 for g in &touch.gestures {
177 match g.group {
178 Some(group) => writeln!(out, " {:<6} {:<12} {} [group {group}]", g.side, g.gesture, g.action)?,
179 None => writeln!(out, " {:<6} {:<12} {}", g.side, g.gesture, g.action)?,
180 }
181 }
182 if let Some(cycle) = &touch.hold_cycle {
183 writeln!(out, " Hold cycles through: {}", cycle.join(" → "))?;
184 }
185 warnings(out, &touch.warnings)?;
186 Ok(())
187}
188
189/// The hold cycle after a change, as the buds report it on reading back. JSON uses the same
190/// `hold_cycle` list as `touch`.
191pub fn hold_cycle(
192 out: &mut impl Write,
193 device: &PairedDevice,
194 model: &dyn Device,
195 now: &[String],
196 as_json: bool,
197) -> Result<(), Failure> {
198 if as_json {
199 return json(out, Some((device, model)), serde_json::json!({ "hold_cycle": now }));
200 }
201 header(out, device, model)?;
202 writeln!(out, " Hold cycles through: {}", now.join(" → "))?;
203 Ok(())
204}
205
206/// A touch-control change: what changed (or would change, on a dry run) and the write.
207pub fn touch_change(
208 out: &mut impl Write,
209 device: &PairedDevice,
210 model: &dyn Device,
211 change: &TouchChange,
212 as_json: bool,
213) -> Result<(), Failure> {
214 if as_json {
215 return json(out, Some((device, model)), serde_json::to_value(change)?);
216 }
217 header(out, device, model)?;
218 if change.changes.is_empty() {
219 writeln!(out, " Nothing to change: the buds already have that action.")?;
220 return Ok(());
221 }
222 for c in &change.changes {
223 writeln!(out, " {:<6} {:<12} {} → {}", c.side, c.gesture, c.from, c.to)?;
224 }
225 if let (false, Some(write)) = (change.sent, &change.write) {
226 writeln!(out, " Dry run, nothing sent. The write would be:")?;
227 writeln!(
228 out,
229 " 0x{:04X} {}",
230 write.command,
231 budctl_core::debug::hex(&write.payload)
232 )?;
233 }
234 Ok(())
235}
236
237/// The noise-control mode after a change, as the buds report it on reading back.
238pub fn noise_control(
239 out: &mut impl Write,
240 device: &PairedDevice,
241 model: &dyn Device,
242 now: &NoiseControl,
243 as_json: bool,
244) -> Result<(), Failure> {
245 if as_json {
246 return json(
247 out,
248 Some((device, model)),
249 serde_json::json!({ "noise_control": serde_json::to_value(now)? }),
250 );
251 }
252 header(out, device, model)?;
253 match &now.level {
254 Some(level) => writeln!(out, " Noise control {} ({level})", now.mode)?,
255 None => writeln!(out, " Noise control {}", now.mode)?,
256 }
257 Ok(())
258}
259
260/// One switch after a change, as the buds report it on reading back. JSON uses the same
261/// `switches` map as `status`.
262pub fn switch(
263 out: &mut impl Write,
264 device: &PairedDevice,
265 model: &dyn Device,
266 now: &Switch,
267 as_json: bool,
268) -> Result<(), Failure> {
269 if as_json {
270 return json(
271 out,
272 Some((device, model)),
273 serde_json::json!({ "switches": { now.key: now.on } }),
274 );
275 }
276 header(out, device, model)?;
277 writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?;
278 Ok(())
279}
280
281/// Every EQ preset, with the one in use marked `*`.
282pub fn eq_presets(
283 out: &mut impl Write,
284 device: &PairedDevice,
285 model: &dyn Device,
286 eq: &EqPresets,
287 as_json: bool,
288) -> Result<(), Failure> {
289 if as_json {
290 return json(out, Some((device, model)), serde_json::to_value(eq)?);
291 }
292 header(out, device, model)?;
293 let current = eq.current.as_deref();
294 for preset in eq.presets.iter().filter(|p| !p.custom) {
295 let mark = if current == Some(preset.name.as_str()) {
296 "*"
297 } else {
298 " "
299 };
300 writeln!(out, " {mark} {}", preset.name)?;
301 }
302 let custom: Vec<_> = eq.presets.iter().filter(|p| p.custom).collect();
303 if custom.is_empty() {
304 writeln!(out, " Custom presets: none")?;
305 } else {
306 writeln!(out, " Custom presets:")?;
307 }
308 for preset in custom {
309 let name = format!("custom-{}", preset.id);
310 let mark = if current == Some(name.as_str()) { "*" } else { " " };
311 let bands: Vec<String> = preset
312 .bands
313 .iter()
314 .map(|b| format!("{} Hz {:+}", b.hz, b.gain))
315 .collect();
316 writeln!(out, " {mark} {name} \"{}\": {}", preset.name, bands.join(", "))?;
317 }
318 warnings(out, &eq.warnings)?;
319 Ok(())
320}
321
322/// The EQ preset after a change, as the buds report it on reading back.
323pub fn eq(
324 out: &mut impl Write,
325 device: &PairedDevice,
326 model: &dyn Device,
327 now: &str,
328 as_json: bool,
329) -> Result<(), Failure> {
330 if as_json {
331 return json(out, Some((device, model)), serde_json::json!({ "eq": now }));
332 }
333 header(out, device, model)?;
334 writeln!(out, " EQ {now}")?;
335 Ok(())
336}
337
338/// After `find start` or `find stop`: the buds acked; there is nothing to read back.
339pub fn find(
340 out: &mut impl Write,
341 device: &PairedDevice,
342 model: &dyn Device,
343 ring: bool,
344 as_json: bool,
345) -> Result<(), Failure> {
346 if as_json {
347 return json(out, Some((device, model)), serde_json::json!({ "ringing": ring }));
348 }
349 header(out, device, model)?;
350 if ring {
351 writeln!(out, " Ringing. Stop with: budctl find stop")?;
352 } else {
353 writeln!(out, " Stopped ringing.")?;
354 }
355 Ok(())
356}