linux-rust: parse single battery of AirPods Max

This commit is contained in:
Kavish Devar
2025-11-23 00:35:09 +05:30
parent 4737cbfc2c
commit 6f0323ee6b
5 changed files with 75 additions and 47 deletions

View File

@@ -215,6 +215,7 @@ pub enum AudioSourceType {
#[repr(u8)] #[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatteryComponent { pub enum BatteryComponent {
Headphone = 1,
Left = 4, Left = 4,
Right = 2, Right = 2,
Case = 8 Case = 8
@@ -476,6 +477,7 @@ impl AACPManager {
let base_index = 3 + i * 5; let base_index = 3 + i * 5;
batteries.push(BatteryInfo { batteries.push(BatteryInfo {
component: match payload[base_index] { component: match payload[base_index] {
0x01 => BatteryComponent::Headphone,
0x02 => BatteryComponent::Right, 0x02 => BatteryComponent::Right,
0x04 => BatteryComponent::Left, 0x04 => BatteryComponent::Left,
0x08 => BatteryComponent::Case, 0x08 => BatteryComponent::Case,

View File

@@ -170,6 +170,10 @@ impl AirPodsDevice {
handle.update(|tray: &mut MyTray| { handle.update(|tray: &mut MyTray| {
for b in &battery_info { for b in &battery_info {
match b.component as u8 { match b.component as u8 {
0x01 => {
tray.battery_headphone = Some(b.level);
tray.battery_headphone_status = Some(b.status);
}
0x02 => { 0x02 => {
tray.battery_r = Some(b.level); tray.battery_r = Some(b.level);
tray.battery_r_status = Some(b.status); tray.battery_r_status = Some(b.status);

View File

@@ -102,6 +102,8 @@ async fn async_main(
} else { } else {
let tray = MyTray { let tray = MyTray {
conversation_detect_enabled: None, conversation_detect_enabled: None,
battery_headphone: None,
battery_headphone_status: None,
battery_l: None, battery_l: None,
battery_l_status: None, battery_l_status: None,
battery_r: None, battery_r: None,

View File

@@ -4,24 +4,26 @@ use ab_glyph::{Font, ScaleFont};
use ksni::{Icon, ToolTip}; use ksni::{Icon, ToolTip};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::bluetooth::aacp::ControlCommandIdentifiers; use crate::bluetooth::aacp::{BatteryStatus, ControlCommandIdentifiers};
use crate::ui::messages::BluetoothUIMessage; use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_app_settings_path; use crate::utils::get_app_settings_path;
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct MyTray { pub struct MyTray {
pub(crate) conversation_detect_enabled: Option<bool>, pub conversation_detect_enabled: Option<bool>,
pub(crate) battery_l: Option<u8>, pub battery_headphone: Option<u8>,
pub(crate) battery_l_status: Option<crate::bluetooth::aacp::BatteryStatus>, pub battery_headphone_status: Option<BatteryStatus>,
pub(crate) battery_r: Option<u8>, pub battery_l: Option<u8>,
pub(crate) battery_r_status: Option<crate::bluetooth::aacp::BatteryStatus>, pub battery_l_status: Option<BatteryStatus>,
pub(crate) battery_c: Option<u8>, pub battery_r: Option<u8>,
pub(crate) battery_c_status: Option<crate::bluetooth::aacp::BatteryStatus>, pub battery_r_status: Option<BatteryStatus>,
pub(crate) connected: bool, pub battery_c: Option<u8>,
pub(crate) listening_mode: Option<u8>, pub battery_c_status: Option<BatteryStatus>,
pub(crate) allow_off_option: Option<u8>, pub connected: bool,
pub(crate) command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>, pub listening_mode: Option<u8>,
pub(crate) ui_tx: Option<UnboundedSender<BluetoothUIMessage>>, pub allow_off_option: Option<u8>,
pub command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
pub ui_tx: Option<UnboundedSender<BluetoothUIMessage>>,
} }
impl ksni::Tray for MyTray { impl ksni::Tray for MyTray {
@@ -34,21 +36,27 @@ impl ksni::Tray for MyTray {
fn icon_pixmap(&self) -> Vec<Icon> { fn icon_pixmap(&self) -> Vec<Icon> {
let text = { let text = {
let mut levels: Vec<u8> = Vec::new(); let mut levels: Vec<u8> = Vec::new();
if let Some(h) = self.battery_headphone {
if self.battery_headphone_status != Some(BatteryStatus::Disconnected) {
levels.push(h);
}
} else {
if let Some(l) = self.battery_l { if let Some(l) = self.battery_l {
if self.battery_l_status != Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) { if self.battery_l_status != Some(BatteryStatus::Disconnected) {
levels.push(l); levels.push(l);
} }
} }
if let Some(r) = self.battery_r { if let Some(r) = self.battery_r {
if self.battery_r_status != Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) { if self.battery_r_status != Some(BatteryStatus::Disconnected) {
levels.push(r); levels.push(r);
} }
} }
// if let Some(c) = self.battery_c { // if let Some(c) = self.battery_c {
// if self.battery_c_status != Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) { // if self.battery_c_status != Some(BatteryStatus::Disconnected) {
// levels.push(c); // levels.push(c);
// } // }
// } // }
}
let min_battery = levels.iter().min().copied(); let min_battery = levels.iter().min().copied();
if let Some(b) = min_battery { if let Some(b) = min_battery {
format!("{}", b) format!("{}", b)
@@ -56,8 +64,8 @@ impl ksni::Tray for MyTray {
"?".to_string() "?".to_string()
} }
}; };
let any_bud_charging = matches!(self.battery_l_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging)) let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging))
|| matches!(self.battery_r_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging)); || matches!(self.battery_r_status, Some(BatteryStatus::Charging));
let app_settings_path = get_app_settings_path(); let app_settings_path = get_app_settings_path();
let settings = std::fs::read_to_string(&app_settings_path) let settings = std::fs::read_to_string(&app_settings_path)
.ok() .ok()
@@ -70,12 +78,12 @@ impl ksni::Tray for MyTray {
vec![icon] vec![icon]
} }
fn tool_tip(&self) -> ToolTip { fn tool_tip(&self) -> ToolTip {
let format_component = |label: &str, level: Option<u8>, status: Option<crate::bluetooth::aacp::BatteryStatus>| -> String { let format_component = |label: &str, level: Option<u8>, status: Option<BatteryStatus>| -> String {
match status { match status {
Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) => format!("{}: -", label), Some(BatteryStatus::Disconnected) => format!("{}: -", label),
_ => { _ => {
let pct = level.map(|b| format!("{}%", b)).unwrap_or("?".to_string()); let pct = level.map(|b| format!("{}%", b)).unwrap_or("?".to_string());
let suffix = if status == Some(crate::bluetooth::aacp::BatteryStatus::Charging) { let suffix = if status == Some(BatteryStatus::Charging) {
"" ""
} else { } else {
"" ""

View File

@@ -610,6 +610,17 @@ impl App {
match self.device_states.get(mac) { match self.device_states.get(mac) {
Some(DeviceState::AirPods(state)) => { Some(DeviceState::AirPods(state)) => {
let b = &state.battery; let b = &state.battery;
let headphone = b.iter().find(|x| x.component == BatteryComponent::Headphone)
.map(|x| x.level);
// if headphones is not None, use only that
if let Some(level) = headphone {
let charging = b.iter().find(|x| x.component == BatteryComponent::Headphone)
.map(|x| x.status == BatteryStatus::Charging).unwrap_or(false);
format!(
"􀺹 {}%{}",
level, if charging {"\u{1002E6}"} else {""}
)
} else {
let left = b.iter().find(|x| x.component == BatteryComponent::Left) let left = b.iter().find(|x| x.component == BatteryComponent::Left)
.map(|x| x.level).unwrap_or_default(); .map(|x| x.level).unwrap_or_default();
let right = b.iter().find(|x| x.component == BatteryComponent::Right) let right = b.iter().find(|x| x.component == BatteryComponent::Right)
@@ -627,6 +638,7 @@ impl App {
left, if left_charging {"\u{1002E6}"} else {""}, right, if right_charging {"\u{1002E6}"} else {""}, case, if case_charging {"\u{1002E6}"} else {""} left, if left_charging {"\u{1002E6}"} else {""}, right, if right_charging {"\u{1002E6}"} else {""}, case, if case_charging {"\u{1002E6}"} else {""}
) )
} }
}
_ => "Connected".to_string(), _ => "Connected".to_string(),
} }
} else { } else {