linux-rust: add tray icon

This commit is contained in:
Kavish Devar
2025-10-21 15:55:33 +05:30
parent 43bfbda21e
commit cf2a242d7c
7 changed files with 1186 additions and 22 deletions

842
linux-rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,4 +12,8 @@ log = "0.4.28"
dbus = "0.9.9"
hex = "0.4.3"
iced = {version = "0.13.1", features = ["tokio", "auto-detect-theme"]}
libpulse-binding = "2.30.1"
libpulse-binding = "2.30.1"
ksni = "0.3.1"
image = "0.25.8"
imageproc = "0.25.0"
ab_glyph = "0.2.32"

View File

@@ -1,10 +1,13 @@
use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent};
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::media_controller::MediaController;
use bluer::Address;
use log::{debug, info};
use std::sync::Arc;
use ksni::Handle;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
use crate::ui::tray::MyTray;
pub struct AirPodsDevice {
pub mac_address: Address,
@@ -13,11 +16,13 @@ pub struct AirPodsDevice {
}
impl AirPodsDevice {
pub async fn new(mac_address: Address) -> Self {
pub async fn new(mac_address: Address, tray_handle: Handle<MyTray>) -> Self {
info!("Creating new AirPodsDevice for {}", mac_address);
let mut aacp_manager = AACPManager::new();
aacp_manager.connect(mac_address).await;
tray_handle.update(|tray: &mut MyTray| tray.connected = true).await;
info!("Sending handshake");
aacp_manager.send_handshake().await.expect(
"Failed to send handshake to AirPods device",
@@ -46,8 +51,52 @@ impl AirPodsDevice {
let media_controller = Arc::new(Mutex::new(MediaController::new(mac_address.to_string())));
let mc_clone = media_controller.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager.set_event_channel(tx).await;
tray_handle.update(|tray: &mut MyTray| tray.command_tx = Some(command_tx)).await;
let aacp_manager_clone = aacp_manager.clone();
tokio::spawn(async move {
while let Some((id, value)) = command_rx.recv().await {
if let Err(e) = aacp_manager_clone.send_control_command(id, &value).await {
log::error!("Failed to send control command: {}", e);
}
}
});
let (listening_mode_tx, mut listening_mode_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager.subscribe_to_control_command(ControlCommandIdentifiers::ListeningMode, listening_mode_tx).await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = listening_mode_rx.recv().await {
tray_handle_clone.update(|tray: &mut MyTray| {
tray.listening_mode = Some(value[0]);
}).await;
}
});
let (allow_off_tx, mut allow_off_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager.subscribe_to_control_command(ControlCommandIdentifiers::AllowOffOption, allow_off_tx).await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = allow_off_rx.recv().await {
tray_handle_clone.update(|tray: &mut MyTray| {
tray.allow_off_option = Some(value[0]);
}).await;
}
});
let (conversation_detect_tx, mut conversation_detect_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager.subscribe_to_control_command(ControlCommandIdentifiers::ConversationDetectConfig, conversation_detect_tx).await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = conversation_detect_rx.recv().await {
tray_handle_clone.update(|tray: &mut MyTray| {
tray.conversation_detect_enabled = Some(value[0] == 0x01);
}).await;
}
});
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
@@ -58,9 +107,33 @@ impl AirPodsDevice {
debug!("Calling handle_ear_detection with old_status: {:?}, new_status: {:?}", old_status, new_status);
controller.handle_ear_detection(old_status, new_status).await;
}
_ => {
debug!("Received unhandled AACP event: {:?}", event);
AACPEvent::BatteryInfo(battery_info) => {
debug!("Received BatteryInfo event: {:?}", battery_info);
tray_handle.update(|tray: &mut MyTray| {
for b in &battery_info {
match b.component as u8 {
0x02 => {
tray.battery_l = Some(b.level);
tray.battery_l_status = Some(b.status);
}
0x04 => {
tray.battery_r = Some(b.level);
tray.battery_r_status = Some(b.status);
}
0x08 => {
tray.battery_c = Some(b.level);
tray.battery_c_status = Some(b.status);
}
_ => {}
}
}
}).await;
debug!("Updated tray with new battery info");
}
AACPEvent::ControlCommand(status) => {
debug!("Received ControlCommand event: {:?}", status);
}
_ => {}
}
}
});

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::{sleep, Instant};
use std::collections::HashMap;
const PSM: u16 = 0x1001;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -31,14 +32,14 @@ pub mod opcodes {
pub const SEND_CONNECTED_MAC: u8 = 0x14;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ControlCommandStatus {
pub identifier: ControlCommandIdentifiers,
pub value: Vec<u8>,
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ControlCommandIdentifiers {
MicMode = 0x01,
ButtonSendMode = 0x05,
@@ -222,6 +223,7 @@ pub enum AACPEvent {
struct AACPManagerState {
sender: Option<mpsc::Sender<Vec<u8>>>,
control_command_status_list: Vec<ControlCommandStatus>,
control_command_subscribers: HashMap<ControlCommandIdentifiers, Vec<mpsc::UnboundedSender<Vec<u8>>>>,
owns: bool,
connected_devices: Vec<ConnectedDevice>,
audio_source: Option<AudioSource>,
@@ -237,6 +239,7 @@ impl AACPManagerState {
AACPManagerState {
sender: None,
control_command_status_list: Vec::new(),
control_command_subscribers: HashMap::new(),
owns: false,
connected_devices: Vec::new(),
audio_source: None,
@@ -352,6 +355,15 @@ impl AACPManager {
state.event_tx = Some(tx);
}
pub async fn subscribe_to_control_command(&self, identifier: ControlCommandIdentifiers, tx: mpsc::UnboundedSender<Vec<u8>>) {
let mut state = self.state.lock().await;
state.control_command_subscribers.entry(identifier).or_default().push(tx);
// send initial value if available
if let Some(status) = state.control_command_status_list.iter().find(|s| s.identifier == identifier) {
let _ = state.control_command_subscribers.get(&identifier).unwrap().last().unwrap().send(status.value.clone());
}
}
pub async fn receive_packet(&self, packet: &[u8]) {
if !packet.starts_with(&HEADER_BYTES) {
debug!("Received packet does not start with expected header: {}", hex::encode(packet));
@@ -433,6 +445,11 @@ impl AACPManager {
if identifier == ControlCommandIdentifiers::OwnsConnection {
state.owns = value_bytes[0] != 0;
}
if let Some(subscribers) = state.control_command_subscribers.get(&identifier) {
for sub in subscribers {
let _ = sub.send(value.clone());
}
}
if let Some(ref tx) = state.event_tx {
let _ = tx.send(AACPEvent::ControlCommand(status));
}
@@ -621,6 +638,16 @@ impl AACPManager {
packet.extend_from_slice(name_bytes);
self.send_data_packet(&packet).await
}
pub async fn send_control_command(&self, identifier: ControlCommandIdentifiers, value: &[u8]) -> Result<()> {
let opcode = [opcodes::CONTROL_COMMAND, 0x00];
let mut data = vec![identifier as u8];
for i in 0..4 {
data.push(value.get(i).copied().unwrap_or(0));
}
let packet = [opcode.as_slice(), data.as_slice()].concat();
self.send_data_packet(&packet).await
}
}
async fn recv_thread(manager: AACPManager, sp: Arc<SeqPacket>) {

View File

@@ -1,6 +1,7 @@
mod bluetooth;
mod airpods;
mod media_controller;
mod ui;
use std::env;
use log::{debug, info};
@@ -12,6 +13,8 @@ use std::collections::HashMap;
use crate::bluetooth::discovery::find_connected_airpods;
use crate::airpods::AirPodsDevice;
use bluer::Address;
use ksni::TrayMethods;
use crate::ui::tray::MyTray;
#[tokio::main]
async fn main() -> bluer::Result<()> {
@@ -21,6 +24,22 @@ async fn main() -> bluer::Result<()> {
env_logger::init();
let tray = MyTray {
conversation_detect_enabled: None,
battery_l: None,
battery_l_status: None,
battery_r: None,
battery_r_status: None,
battery_c: None,
battery_c_status: None,
connected: false,
listening_mode: None,
allow_off_option: None,
command_tx: None,
};
let handle = tray.spawn().await.unwrap();
let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
@@ -32,7 +51,7 @@ async fn main() -> bluer::Result<()> {
Ok(device) => {
let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string());
info!("Found connected AirPods: {}, initializing.", name);
let _airpods_device = AirPodsDevice::new(device.address()).await;
let _airpods_device = AirPodsDevice::new(device.address(), handle.clone()).await;
}
Err(_) => {
info!("No connected AirPods found.");
@@ -41,7 +60,7 @@ async fn main() -> bluer::Result<()> {
let conn = Connection::new_system()?;
let rule = MatchRule::new_signal("org.freedesktop.DBus.Properties", "PropertiesChanged");
conn.add_match(rule, |_: (), conn, msg| {
conn.add_match(rule, move |_: (), conn, msg| {
let Some(path) = msg.path() else { return true; };
if !path.contains("/org/bluez/hci") || !path.contains("/dev_") {
return true;
@@ -68,8 +87,9 @@ async fn main() -> bluer::Result<()> {
let Ok(addr_str) = proxy.get::<String>("org.bluez.Device1", "Address") else { return true; };
let Ok(addr) = addr_str.parse::<Address>() else { return true; };
info!("AirPods connected: {}, initializing", name);
let handle_clone = handle.clone();
tokio::spawn(async move {
let _airpods_device = AirPodsDevice::new(addr).await;
let _airpods_device = AirPodsDevice::new(addr, handle_clone).await;
});
true
})?;

1
linux-rust/src/ui/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod tray;

223
linux-rust/src/ui/tray.rs Normal file
View File

@@ -0,0 +1,223 @@
// use ksni::TrayMethods; // provides the spawn method
use ab_glyph::{Font, ScaleFont};
use ksni::{Icon, ToolTip};
use crate::bluetooth::aacp::ControlCommandIdentifiers;
#[derive(Debug)]
pub(crate) struct MyTray {
pub(crate) conversation_detect_enabled: Option<bool>,
pub(crate) battery_l: Option<u8>,
pub(crate) battery_l_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) battery_r: Option<u8>,
pub(crate) battery_r_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) battery_c: Option<u8>,
pub(crate) battery_c_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) connected: bool,
pub(crate) listening_mode: Option<u8>,
pub(crate) allow_off_option: Option<u8>,
pub(crate) command_tx: Option<tokio::sync::mpsc::UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
}
impl ksni::Tray for MyTray {
fn id(&self) -> String {
env!("CARGO_PKG_NAME").into()
}
fn title(&self) -> String {
"AirPods".into()
}
fn icon_pixmap(&self) -> Vec<Icon> {
// text to icon pixmap
let text = if self.connected {
let min_battery = match (self.battery_l, self.battery_r) {
(Some(l), Some(r)) => Some(l.min(r)),
(Some(l), None) => Some(l),
(None, Some(r)) => Some(r),
(None, None) => None,
};
min_battery.map(|b| format!("{}%", b)).unwrap_or("?".to_string())
} else {
"D".into()
};
let icon = icon_from_text(&text, true);
vec![icon]
}
fn tool_tip(&self) -> ToolTip {
if self.connected {
let l = self.battery_l.map(|b| format!("L: {}%", b)).unwrap_or("L: ?".to_string());
let l_status = self.battery_l_status.map(|s| format!(" ({:?})", s)).unwrap_or("".to_string());
let r = self.battery_r.map(|b| format!("R: {}%", b)).unwrap_or("R: ?".to_string());
let r_status = self.battery_r_status.map(|s| format!(" ({:?})", s)).unwrap_or("".to_string());
let c = self.battery_c.map(|b| format!("C: {}%", b)).unwrap_or("C: ?".to_string());
let c_status = self.battery_c_status.map(|s| format!(" ({:?})", s)).unwrap_or("".to_string());
ToolTip {
icon_name: "".to_string(),
icon_pixmap: vec![],
title: "Battery Status".to_string(),
description: format!("{}{} {}{} {}{}", l, l_status, r, r_status, c, c_status),
}
} else {
ToolTip {
icon_name: "".to_string(),
icon_pixmap: vec![],
title: "Not Connected".to_string(),
description: "Device is not connected.".to_string(),
}
}
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
let allow_off = self.allow_off_option == Some(0x01);
let options = if allow_off {
vec![
("Off", 0x01),
("ANC", 0x02),
("Transparency", 0x03),
("Adaptive", 0x04),
]
} else {
vec![
("ANC", 0x02),
("Transparency", 0x03),
("Adaptive", 0x04),
]
};
let selected = self.listening_mode.and_then(|mode| {
options.iter().position(|&(_, val)| val == mode)
}).unwrap_or(0);
let options_clone = options.clone();
vec![
RadioGroup {
selected,
select: Box::new(move |this: &mut Self, current| {
if let Some(tx) = &this.command_tx {
let value = options_clone.get(current).map(|&(_, val)| val).unwrap_or(0x02);
let _ = tx.send((ControlCommandIdentifiers::ListeningMode, vec![value]));
}
}),
options: options.into_iter().map(|(label, _)| RadioItem {
label: label.into(),
..Default::default()
}).collect(),
..Default::default()
}
.into(),
CheckmarkItem {
label: "Conversation Detection".into(),
checked: self.conversation_detect_enabled.unwrap_or(false),
enabled: self.conversation_detect_enabled.is_some(),
activate: Box::new(|this: &mut Self| {
if let Some(tx) = &this.command_tx {
if let Some(is_enabled) = this.conversation_detect_enabled {
let value = if is_enabled { 0x02 } else { 0x01 };
let _ = tx.send((ControlCommandIdentifiers::ConversationDetectConfig, vec![value]));
}
}
}),
..Default::default()
}
.into(),
StandardItem {
label: "Exit".into(),
icon_name: "application-exit".into(),
activate: Box::new(|_| std::process::exit(0)),
..Default::default()
}
.into(),
]
}
}
fn icon_from_text(text: &str, text_mode: bool) -> Icon {
use ab_glyph::{FontRef, PxScale};
use image::{ImageBuffer, Rgba};
use imageproc::drawing::draw_text_mut;
let width = 64;
let height = 64;
let mut img = ImageBuffer::from_fn(width, height, |_, _| Rgba([0u8, 0u8, 0u8, 0u8]));
if !text_mode {
let percentage = if text.ends_with('%') {
text.trim_end_matches('%').parse::<f32>().unwrap_or(0.0) / 100.0
} else {
0.0
};
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let inner_radius = 22.0;
let outer_radius = 28.0;
// ring background
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > inner_radius && dist <= outer_radius {
img.put_pixel(x, y, Rgba([128u8, 128u8, 128u8, 255u8]));
}
}
}
// ring
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > inner_radius && dist <= outer_radius {
let angle = dy.atan2(dx);
let angle_from_top = (std::f32::consts::PI / 2.0 - angle).rem_euclid(2.0 * std::f32::consts::PI);
if angle_from_top <= percentage * 2.0 * std::f32::consts::PI {
img.put_pixel(x, y, Rgba([0u8, 255u8, 0u8, 255u8]));
}
}
}
}
} else {
// battery text
let font_data = include_bytes!("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf");
let font = match FontRef::try_from_slice(font_data) {
Ok(f) => f,
Err(_) => {
return Icon {
width: width as i32,
height: height as i32,
data: vec![0u8; (width * height * 4) as usize],
};
}
};
let scale = PxScale::from(28.0);
let color = Rgba([255u8, 255u8, 255u8, 255u8]);
let scaled_font = font.as_scaled(scale);
let mut text_width = 0.0;
for c in text.chars() {
let glyph_id = font.glyph_id(c);
text_width += scaled_font.h_advance(glyph_id);
}
let x = ((width as f32 - text_width) / 2.0).max(0.0) as i32;
let y = ((height as f32 - scale.y) / 2.0).max(0.0) as i32;
draw_text_mut(&mut img, color, x, y, scale, &font, text);
}
let mut data = Vec::with_capacity((width * height * 4) as usize);
for pixel in img.pixels() {
data.push(pixel[3]);
data.push(pixel[0]);
data.push(pixel[1]);
data.push(pixel[2]);
}
Icon {
width: width as i32,
height: height as i32,
data,
}
}