Krolyxon
Commit 4c49f6c

Branches

Refs →

budctlcratesbudctl-clisrcoutput.rs

348 lines · 10.1 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::{BudFit, Charge, 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/// The EQ preset after a change, as the buds report it on reading back.
282pub fn eq(
283 out: &mut impl Write,
284 device: &PairedDevice,
285 model: &dyn Device,
286 now: &str,
287 as_json: bool,
288) -> Result<(), Failure> {
289 if as_json {
290 return json(out, Some((device, model)), serde_json::json!({ "eq": now }));
291 }
292 header(out, device, model)?;
293 writeln!(out, " EQ {now}")?;
294 Ok(())
295}
296
297/// The fit-test result of each bud.
298pub fn fit_test(
299 out: &mut impl Write,
300 device: &PairedDevice,
301 model: &dyn Device,
302 buds: &[BudFit],
303 as_json: bool,
304) -> Result<(), Failure> {
305 if as_json {
306 return json(out, Some((device, model)), serde_json::json!({ "fit": buds }));
307 }
308 header(out, device, model)?;
309 for bud in buds {
310 writeln!(out, " {:<6} {}", bud.side, bud.fit)?;
311 }
312 Ok(())
313}
314
315/// After `fit-test stop`: the buds acked.
316pub fn fit_test_stopped(
317 out: &mut impl Write,
318 device: &PairedDevice,
319 model: &dyn Device,
320 as_json: bool,
321) -> Result<(), Failure> {
322 if as_json {
323 return json(out, Some((device, model)), serde_json::json!({ "fit_test": "stopped" }));
324 }
325 header(out, device, model)?;
326 writeln!(out, " Fit test stopped.")?;
327 Ok(())
328}
329
330/// After `find start` or `find stop`: the buds acked; there is nothing to read back.
331pub fn find(
332 out: &mut impl Write,
333 device: &PairedDevice,
334 model: &dyn Device,
335 ring: bool,
336 as_json: bool,
337) -> Result<(), Failure> {
338 if as_json {
339 return json(out, Some((device, model)), serde_json::json!({ "ringing": ring }));
340 }
341 header(out, device, model)?;
342 if ring {
343 writeln!(out, " Ringing. Stop with: budctl find stop")?;
344 } else {
345 writeln!(out, " Stopped ringing.")?;
346 }
347 Ok(())
348}