//! Printing results, as text for people or as JSON (`--json`) for scripts and status bars. use std::io::{self, Write}; use budctl_core::PairedDevice; use budctl_core::devices::Device; use budctl_core::status::{ Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, }; use budctl_core::{Error, Found}; use serde_json::Value; /// Why a command did not finish: budctl failed, or stdout did. pub enum Failure { Budctl(Error), Io(io::Error), } impl From for Failure { fn from(error: Error) -> Self { Failure::Budctl(error) } } impl From for Failure { fn from(error: io::Error) -> Self { Failure::Io(error) } } impl From for Failure { fn from(error: serde_json::Error) -> Self { Failure::Io(io::Error::from(error)) } } /// Print `body` inside the JSON envelope every result shares: `schema`, plus `device` and /// `address` when the result is about one pair of buds. fn json(out: &mut impl Write, target: Option<(&PairedDevice, &dyn Device)>, body: Value) -> Result<(), Failure> { let mut envelope = serde_json::Map::new(); envelope.insert("schema".to_owned(), JSON_SCHEMA.into()); if let Some((device, model)) = target { envelope.insert("device".to_owned(), model.name().into()); envelope.insert("address".to_owned(), device.address.to_string().into()); } if let Value::Object(fields) = body { envelope.extend(fields); } serde_json::to_writer(&mut *out, &envelope)?; writeln!(out)?; Ok(()) } pub fn list(out: &mut impl Write, found: &[Found], as_json: bool) -> Result<(), Failure> { if as_json { let devices: Vec = found .iter() .map(|f| { serde_json::json!({ "address": f.device.address, "name": f.device.name, "alias": f.device.alias, "connected": f.device.connected, // The model budctl recognized, or null. "model": f.model.map(|m| m.name()), }) }) .collect(); return json(out, None, serde_json::json!({ "devices": devices })); } if found.is_empty() { writeln!(out, "No paired Bluetooth devices.")?; return Ok(()); } for f in found { let state = if f.device.connected { "connected" } else { "not connected" }; let model = match f.model { Some(model) => format!("supported: {}", model.name()), None => "not supported".to_owned(), }; writeln!(out, "{} {} ({state}, {model})", f.device.address, f.device.alias)?; } Ok(()) } fn charge(charge: Option) -> String { match charge { Some(Charge { level, charging: true }) => format!("{level}% (charging)"), Some(Charge { level, charging: false }) => format!("{level}%"), None => "—".to_owned(), } } fn header(out: &mut impl Write, device: &PairedDevice, model: &dyn Device) -> io::Result<()> { writeln!(out, "{} ({})", model.name(), device.address) } fn warnings(out: &mut impl Write, warnings: &[String]) -> io::Result<()> { for warning in warnings { writeln!(out, " warning: {warning}")?; } Ok(()) } pub fn status( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, status: &Status, as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::to_value(status)?); } header(out, device, model)?; let battery = &status.battery; let mut rows: Vec<(&str, String)> = vec![( "Battery", format!( "L {} R {} case {}", charge(battery.left), charge(battery.right), charge(battery.case) ), )]; if let Some(noise) = &status.noise_control { let value = match &noise.level { Some(level) => format!("{} ({level})", noise.mode), None => noise.mode.clone(), }; rows.push(("Noise control", value)); } if let Some(eq) = &status.eq { rows.push(("EQ", eq.clone())); } if let Some(bass) = status.bass_level { rows.push(("Bass level", bass.to_string())); } for switch in &status.switches { rows.push((switch.label, if switch.on { "on" } else { "off" }.to_owned())); } if let Some(codec) = &status.codec { rows.push(("Codec", codec.clone())); } if let Some(firmware) = &status.firmware { rows.push(("Firmware", firmware.clone())); } // Labels are as wide as the longest one, so values line up whatever the device names. let width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0); for (label, value) in &rows { writeln!(out, " {label: Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::to_value(touch)?); } header(out, device, model)?; if touch.gestures.is_empty() { writeln!(out, " (no gestures reported)")?; } for g in &touch.gestures { match g.group { Some(group) => writeln!(out, " {:<6} {:<12} {} [group {group}]", g.side, g.gesture, g.action)?, None => writeln!(out, " {:<6} {:<12} {}", g.side, g.gesture, g.action)?, } } if let Some(cycle) = &touch.hold_cycle { writeln!(out, " Hold cycles through: {}", cycle.join(" → "))?; } warnings(out, &touch.warnings)?; Ok(()) } /// The hold cycle after a change, as the buds report it on reading back. JSON uses the same /// `hold_cycle` list as `touch`. pub fn hold_cycle( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &[String], as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::json!({ "hold_cycle": now })); } header(out, device, model)?; writeln!(out, " Hold cycles through: {}", now.join(" → "))?; Ok(()) } /// A touch-control change: what changed (or would change, on a dry run) and the write. pub fn touch_change( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, change: &TouchChange, as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::to_value(change)?); } header(out, device, model)?; if change.changes.is_empty() { writeln!(out, " Nothing to change: the buds already have that action.")?; return Ok(()); } for c in &change.changes { writeln!(out, " {:<6} {:<12} {} → {}", c.side, c.gesture, c.from, c.to)?; } if let (false, Some(write)) = (change.sent, &change.write) { writeln!(out, " Dry run, nothing sent. The write would be:")?; writeln!( out, " 0x{:04X} {}", write.command, budctl_core::debug::hex(&write.payload) )?; } Ok(()) } /// The noise-control mode after a change, as the buds report it on reading back. pub fn noise_control( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &NoiseControl, as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "noise_control": serde_json::to_value(now)? }), ); } header(out, device, model)?; match &now.level { Some(level) => writeln!(out, " Noise control {} ({level})", now.mode)?, None => writeln!(out, " Noise control {}", now.mode)?, } Ok(()) } /// One switch after a change, as the buds report it on reading back. JSON uses the same /// `switches` map as `status`. pub fn switch( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &Switch, as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "switches": { now.key: now.on } }), ); } header(out, device, model)?; writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?; Ok(()) } /// Every EQ preset, with the one in use marked `*`. pub fn eq_presets( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, eq: &EqPresets, as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::to_value(eq)?); } header(out, device, model)?; let current = eq.current.as_deref(); for preset in eq.presets.iter().filter(|p| !p.custom) { let mark = if current == Some(preset.name.as_str()) { "*" } else { " " }; writeln!(out, " {mark} {}", preset.name)?; } let custom: Vec<_> = eq.presets.iter().filter(|p| p.custom).collect(); if custom.is_empty() { writeln!(out, " Custom presets: none")?; } else { writeln!(out, " Custom presets:")?; } for preset in custom { let name = format!("custom-{}", preset.id); let mark = if current == Some(name.as_str()) { "*" } else { " " }; let bands: Vec = preset .bands .iter() .map(|b| format!("{} Hz {:+}", b.hz, b.gain)) .collect(); writeln!(out, " {mark} {name} \"{}\": {}", preset.name, bands.join(", "))?; } warnings(out, &eq.warnings)?; Ok(()) } /// One custom preset after it was saved and selected, as the buds list it on reading back. pub fn custom_eq( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &EqPreset, as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "eq": format!("custom-{}", now.id), "preset": now }), ); } header(out, device, model)?; let bands: Vec = now.bands.iter().map(|b| format!("{} Hz {:+}", b.hz, b.gain)).collect(); writeln!(out, " EQ custom-{} \"{}\": {}", now.id, now.name, bands.join(", "))?; Ok(()) } /// A custom preset just created, as the buds list it. pub fn created_eq( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, created: &EqPreset, as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "created": format!("custom-{}", created.id), "preset": created }), ); } header(out, device, model)?; let bands: Vec = created .bands .iter() .map(|b| format!("{} Hz {:+}", b.hz, b.gain)) .collect(); writeln!( out, " New preset custom-{} \"{}\": {}", created.id, created.name, bands.join(", ") )?; Ok(()) } /// The custom presets left after deleting one, as the buds list them. pub fn deleted_eq( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, deleted: &str, left: &[EqPreset], as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "deleted": deleted, "presets": left }), ); } header(out, device, model)?; writeln!(out, " Deleted {deleted}")?; for preset in left { writeln!(out, " Left custom-{} \"{}\"", preset.id, preset.name)?; } Ok(()) } /// A custom preset just renamed, as the buds list it. pub fn renamed_eq( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &EqPreset, as_json: bool, ) -> Result<(), Failure> { if as_json { return json( out, Some((device, model)), serde_json::json!({ "renamed": format!("custom-{}", now.id), "preset": now }), ); } header(out, device, model)?; writeln!(out, " Renamed custom-{} \"{}\"", now.id, now.name)?; Ok(()) } /// The EQ preset after a change, as the buds report it on reading back. pub fn eq( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, now: &str, as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::json!({ "eq": now })); } header(out, device, model)?; writeln!(out, " EQ {now}")?; Ok(()) } /// After `bass `: the buds acked; they cannot report the level back. pub fn bass_level( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, level: i8, as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::json!({ "bass_level": level })); } header(out, device, model)?; writeln!( out, " Bass level {level:+} (accepted; the buds cannot report it back)" )?; Ok(()) } /// The devices dual connection keeps: name, address, and whether each is connected. pub fn dual_devices( out: &mut impl Write, device: &PairedDevice, model: &dyn Device, devices: &[DualDevice], as_json: bool, ) -> Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::json!({ "devices": devices })); } header(out, device, model)?; if devices.is_empty() { writeln!(out, " No devices listed.")?; } let width = devices.iter().map(|d| d.name.chars().count()).max().unwrap_or(0); for d in devices { let mut state = vec![if d.connected { "connected" } else { "not connected" }]; for (on, what) in [ (d.this_computer, "this computer"), (d.main_audio, "main audio"), (d.playing, "playing"), ] { if on { state.push(what); } } writeln!(out, " {: Result<(), Failure> { if as_json { return json(out, Some((device, model)), serde_json::json!({ "ringing": ring })); } header(out, device, model)?; if ring { writeln!(out, " Ringing. Stop with: budctl find stop")?; } else { writeln!(out, " Stopped ringing.")?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use budctl_core::devices::REGISTRY; #[test] fn dual_devices_line_up_and_keep_their_json_names() { let buds = PairedDevice { address: "88:0E:85:46:FE:5C".parse().unwrap(), name: "realme Buds T310".to_owned(), alias: "realme Buds T310".to_owned(), connected: true, uuids: Vec::new(), }; let model = *REGISTRY.first().unwrap(); let devices = [ DualDevice { address: "2B:1A:00:AA:BB:CC".parse().unwrap(), name: "arch".to_owned(), connected: true, this_computer: true, main_audio: true, playing: false, }, DualDevice { address: "11:22:33:44:55:66".parse().unwrap(), name: "Pixel 8".to_owned(), connected: false, this_computer: false, main_audio: false, playing: false, }, ]; let mut text = Vec::new(); assert!(dual_devices(&mut text, &buds, model, &devices, false).is_ok()); assert_eq!( String::from_utf8(text).unwrap(), "realme Buds T310 (88:0E:85:46:FE:5C)\n\ \x20 arch 2B:1A:00:AA:BB:CC connected, this computer, main audio\n\ \x20 Pixel 8 11:22:33:44:55:66 not connected\n" ); let mut json_out = Vec::new(); assert!(dual_devices(&mut json_out, &buds, model, &devices, true).is_ok()); let value: Value = serde_json::from_slice(&json_out).unwrap(); assert_eq!(value["schema"], 1); assert_eq!(value["devices"][0]["this_computer"], true); assert_eq!(value["devices"][1]["address"], "11:22:33:44:55:66"); assert_eq!(value["devices"][1]["connected"], false); let mut empty = Vec::new(); assert!(dual_devices(&mut empty, &buds, model, &[], false).is_ok()); assert!(String::from_utf8(empty).unwrap().ends_with(" No devices listed.\n")); } }