From e9470e2b44d8d705c1021e9528586f5bce08b8d5 Mon Sep 17 00:00:00 2001 From: Cristian Rubio Date: Fri, 19 Jun 2026 01:17:13 -0500 Subject: [PATCH] linux-rust: log device status in headless mode, clean up logger init In --no-tray mode the BluetoothUIMessage channel (connection, battery, noise control mode) was produced but never consumed, leaving headless users with no visibility into device state. Add a small console consumer that logs each field only when it changes. Also moves logger setup into an explicit env_logger::Builder instead of mutating RUST_LOG via an unsafe env::set_var call. --- linux-rust/src/headless.rs | 88 ++++++++++++++++++++++++++++++++++++++ linux-rust/src/main.rs | 36 ++++++++++------ 2 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 linux-rust/src/headless.rs diff --git a/linux-rust/src/headless.rs b/linux-rust/src/headless.rs new file mode 100644 index 000000000..dccc2e21d --- /dev/null +++ b/linux-rust/src/headless.rs @@ -0,0 +1,88 @@ +use crate::bluetooth::aacp::{AACPEvent, BatteryComponent, BatteryInfo, BatteryStatus, ControlCommandIdentifiers}; +use crate::devices::enums::AirPodsNoiseControlMode; +use crate::ui::messages::BluetoothUIMessage; +use log::info; +use std::collections::{HashMap, HashSet}; +use tokio::sync::mpsc::UnboundedReceiver; + +fn battery_label(component: BatteryComponent) -> &'static str { + match component { + BatteryComponent::Headphone => "Headphones", + BatteryComponent::Left => "L", + BatteryComponent::Right => "R", + BatteryComponent::Case => "Case", + } +} + +fn format_battery(info: &BatteryInfo) -> String { + match info.status { + BatteryStatus::Disconnected => format!("{}: disconnected", battery_label(info.component)), + BatteryStatus::Charging => format!("{}: {}% (charging)", battery_label(info.component), info.level), + BatteryStatus::NotCharging => format!("{}: {}%", battery_label(info.component), info.level), + } +} + +#[derive(Default)] +struct HeadlessStatus { + connected: HashSet, + battery: HashMap>, + listening_mode: HashMap, +} + +/// Consumes `BluetoothUIMessage` in `--no-tray` mode (otherwise only the tray/GUI read this +/// channel) and logs `info!` lines for connection, battery, and noise control mode changes, +/// skipping events that don't change the last-known value. +pub async fn run_headless_console(mut ui_rx: UnboundedReceiver) { + let mut status = HeadlessStatus::default(); + while let Some(message) = ui_rx.recv().await { + match message { + BluetoothUIMessage::DeviceConnected(mac) => { + if status.connected.insert(mac.clone()) { + info!("Connected: {}", mac); + } + } + BluetoothUIMessage::DeviceDisconnected(mac) => { + if status.connected.remove(&mac) { + info!("Disconnected: {}", mac); + } + status.battery.remove(&mac); + status.listening_mode.remove(&mac); + } + BluetoothUIMessage::AACPUIEvent(mac, event) => match event { + AACPEvent::BatteryInfo(battery_info) => { + let last = status.battery.entry(mac.clone()).or_default(); + for component_info in &battery_info { + let changed = last + .iter() + .find(|b| b.component == component_info.component) + .map(|b| b != component_info) + .unwrap_or(true); + if changed { + info!("{} battery — {}", mac, format_battery(component_info)); + } + } + *last = battery_info; + } + AACPEvent::ControlCommand(status_update) + if status_update.identifier == ControlCommandIdentifiers::ListeningMode => + { + let mode = status_update + .value + .first() + .map(AirPodsNoiseControlMode::from_byte) + .unwrap_or(AirPodsNoiseControlMode::Transparency); + let mode_byte = mode.to_byte(); + let changed = status.listening_mode.get(&mac) != Some(&mode_byte); + if changed { + info!("{} noise control mode: {}", mac, mode); + status.listening_mode.insert(mac, mode_byte); + } + } + _ => {} + }, + BluetoothUIMessage::OpenWindow + | BluetoothUIMessage::ATTNotification(_, _, _) + | BluetoothUIMessage::NoOp => {} + } + } +} diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..959580728 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -1,5 +1,6 @@ mod bluetooth; mod devices; +mod headless; mod media_controller; mod ui; mod utils; @@ -8,6 +9,7 @@ use crate::bluetooth::discovery::{find_connected_airpods, find_other_managed_dev use crate::bluetooth::le::start_le_monitor; use crate::bluetooth::managers::DeviceManagers; use crate::devices::enums::DeviceData; +use crate::headless::run_headless_console; use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; use crate::utils::{get_app_settings_path, get_devices_path}; @@ -47,6 +49,24 @@ struct Args { version: bool } +/// Default log filter: global level at `app_level`, with known-noisy external crates kept quiet +/// unless the user already set `RUST_LOG` themselves, in which case that takes precedence. +fn default_log_filter(debug: bool, le_debug: bool) -> String { + let app_level = if debug { "debug" } else { "info" }; + let le_level = if le_debug { "debug" } else { "info" }; + format!( + "{app_level},zbus=warn,winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods::bluetooth::le={le_level}" + ) +} + +fn init_logging(debug: bool, le_debug: bool) { + let env = env_logger::Env::default() + .filter_or("RUST_LOG", default_log_filter(debug, le_debug)); + env_logger::Builder::from_env(env) + .format_timestamp_secs() + .init(); +} + fn main() -> iced::Result { let args = Args::parse(); @@ -58,24 +78,11 @@ fn main() -> iced::Result { return Ok(()); } - let log_level = if args.debug { "debug" } else { "info" }; // let wayland_display = env::var("WAYLAND_DISPLAY").is_ok(); // if wayland_display && env::var("WGPU_BACKEND").is_err() { // unsafe { env::set_var("WGPU_BACKEND", "gl") }; // } - if env::var("RUST_LOG").is_err() { - unsafe { - env::set_var( - "RUST_LOG", - log_level.to_owned() - + &format!( - ",zbus=warn,winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods::bluetooth::le={}", - if args.le_debug { "debug" } else { "info" } - ), - ) - }; - } - env_logger::init(); + init_logging(args.debug, args.le_debug); let (ui_tx, ui_rx) = unbounded_channel::(); @@ -87,6 +94,7 @@ fn main() -> iced::Result { // Run headless without UI info!("Running in headless mode (no GUI)"); let rt = tokio::runtime::Runtime::new().unwrap(); + rt.spawn(run_headless_console(ui_rx)); rt.block_on(async_main(ui_tx, device_managers)).unwrap(); Ok(()) } else {