| 1 | //! Printing results, as text for people or as JSON (`--json`) for scripts and status bars. |
| 2 | |
| 3 | use std::io::{self, Write}; |
| 4 | |
| 5 | use budctl_core::bluez::PairedDevice; |
| 6 | use budctl_core::devices::Device; |
| 7 | use budctl_core::status::{ |
| 8 | Charge, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, |
| 9 | }; |
| 10 | use budctl_core::{Error, Found}; |
| 11 | use serde_json::Value; |
| 12 | |
| 13 | /// Why a command did not finish: budctl failed, or stdout did. |
| 14 | pub enum Failure { |
| 15 | Budctl(Error), |
| 16 | Io(io::Error), |
| 17 | } |
| 18 | |
| 19 | impl From<Error> for Failure { |
| 20 | fn from(error: Error) -> Self { |
| 21 | Failure::Budctl(error) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | impl From<io::Error> for Failure { |
| 26 | fn from(error: io::Error) -> Self { |
| 27 | Failure::Io(error) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | impl 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. |
| 39 | fn 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 | |
| 54 | pub 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 | |
| 91 | fn 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 | |
| 99 | fn header(out: &mut impl Write, device: &PairedDevice, model: &dyn Device) -> io::Result<()> { |
| 100 | writeln!(out, "{} ({})", model.name(), device.address) |
| 101 | } |
| 102 | |
| 103 | fn warnings(out: &mut impl Write, warnings: &[String]) -> io::Result<()> { |
| 104 | for warning in warnings { |
| 105 | writeln!(out, " warning: {warning}")?; |
| 106 | } |
| 107 | Ok(()) |
| 108 | } |
| 109 | |
| 110 | pub 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 | |
| 163 | pub 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`. |
| 193 | pub 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. |
| 209 | pub 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. |
| 240 | pub 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`. |
| 264 | pub 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 `*`. |
| 284 | pub 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. |
| 325 | pub 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. |
| 346 | pub 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. |
| 377 | pub 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. |
| 401 | pub 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. |
| 421 | pub 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. |
| 437 | pub 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 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. |
| 456 | pub fn find( |
| 457 | out: &mut impl Write, |
| 458 | device: &PairedDevice, |
| 459 | model: &dyn Device, |
| 460 | ring: bool, |
| 461 | as_json: bool, |
| 462 | ) -> Result<(), Failure> { |
| 463 | if as_json { |
| 464 | return json(out, Some((device, model)), serde_json::json!({ "ringing": ring })); |
| 465 | } |
| 466 | header(out, device, model)?; |
| 467 | if ring { |
| 468 | writeln!(out, " Ringing. Stop with: budctl find stop")?; |
| 469 | } else { |
| 470 | writeln!(out, " Stopped ringing.")?; |
| 471 | } |
| 472 | Ok(()) |
| 473 | } |