| 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::{Charge, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; |
| 8 | use budctl_core::{Error, Found}; |
| 9 | use serde_json::Value; |
| 10 | |
| 11 | /// Why a command did not finish: budctl failed, or stdout did. |
| 12 | pub enum Failure { |
| 13 | Budctl(Error), |
| 14 | Io(io::Error), |
| 15 | } |
| 16 | |
| 17 | impl From<Error> for Failure { |
| 18 | fn from(error: Error) -> Self { |
| 19 | Failure::Budctl(error) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | impl From<io::Error> for Failure { |
| 24 | fn from(error: io::Error) -> Self { |
| 25 | Failure::Io(error) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | impl 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. |
| 37 | fn 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 | |
| 52 | pub 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 | |
| 89 | fn 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 | |
| 97 | fn header(out: &mut impl Write, device: &PairedDevice, model: &dyn Device) -> io::Result<()> { |
| 98 | writeln!(out, "{} ({})", model.name(), device.address) |
| 99 | } |
| 100 | |
| 101 | fn warnings(out: &mut impl Write, warnings: &[String]) -> io::Result<()> { |
| 102 | for warning in warnings { |
| 103 | writeln!(out, " warning: {warning}")?; |
| 104 | } |
| 105 | Ok(()) |
| 106 | } |
| 107 | |
| 108 | pub 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 | |
| 161 | pub 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`. |
| 191 | pub 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. |
| 207 | pub 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. |
| 238 | pub 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`. |
| 262 | pub 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 `*`. |
| 282 | pub 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. |
| 323 | pub 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 `bass <level>`: the buds acked; they cannot report the level back. |
| 339 | pub fn bass_level( |
| 340 | out: &mut impl Write, |
| 341 | device: &PairedDevice, |
| 342 | model: &dyn Device, |
| 343 | level: i8, |
| 344 | as_json: bool, |
| 345 | ) -> Result<(), Failure> { |
| 346 | if as_json { |
| 347 | return json(out, Some((device, model)), serde_json::json!({ "bass_level": level })); |
| 348 | } |
| 349 | header(out, device, model)?; |
| 350 | writeln!( |
| 351 | out, |
| 352 | " Bass level {level:+} (accepted; the buds cannot report it back)" |
| 353 | )?; |
| 354 | Ok(()) |
| 355 | } |
| 356 | |
| 357 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. |
| 358 | pub fn find( |
| 359 | out: &mut impl Write, |
| 360 | device: &PairedDevice, |
| 361 | model: &dyn Device, |
| 362 | ring: bool, |
| 363 | as_json: bool, |
| 364 | ) -> Result<(), Failure> { |
| 365 | if as_json { |
| 366 | return json(out, Some((device, model)), serde_json::json!({ "ringing": ring })); |
| 367 | } |
| 368 | header(out, device, model)?; |
| 369 | if ring { |
| 370 | writeln!(out, " Ringing. Stop with: budctl find stop")?; |
| 371 | } else { |
| 372 | writeln!(out, " Stopped ringing.")?; |
| 373 | } |
| 374 | Ok(()) |
| 375 | } |