Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion linux-rust/src/bluetooth/aacp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,35 @@ pub enum StemPressType {
LongPress = 0x08,
}

impl StemPressType {
fn from_u8(value: u8) -> Option<Self> {
match value {
0x05 => Some(Self::SinglePress),
0x06 => Some(Self::DoublePress),
0x07 => Some(Self::TriplePress),
0x08 => Some(Self::LongPress),
_ => None,
}
}
}

#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StemPressBudType {
Left = 0x01,
Right = 0x02,
}

impl StemPressBudType {
fn from_u8(value: u8) -> Option<Self> {
match value {
0x01 => Some(Self::Left),
0x02 => Some(Self::Right),
_ => None,
}
}
}

#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioSourceType {
Expand Down Expand Up @@ -283,6 +305,7 @@ pub enum AACPEvent {
AudioSource(AudioSource),
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
OwnershipToFalseRequest,
StemPress(StemPressType, StemPressBudType),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -795,7 +818,26 @@ impl AACPManager {
error!("Failed to save devices: {}", e);
}
}
opcodes::STEM_PRESS => info!("Received Stem Press packet."),
opcodes::STEM_PRESS => {
if payload.len() < 4 {
error!("Stem Press packet too short: {}", hex::encode(payload));
return;
}
let press_type = StemPressType::from_u8(payload[2]);
let bud_type = StemPressBudType::from_u8(payload[3]);
if let (Some(press), Some(bud)) = (press_type, bud_type) {
info!("Received Stem Press: {:?} on {:?}", press, bud);
let state = self.state.lock().await;
if let Some(ref tx) = state.event_tx {
let _ = tx.send(AACPEvent::StemPress(press, bud));
}
} else {
error!(
"Invalid Stem Press packet - type: {:?}, bud: {:?}",
press_type, bud_type
);
}
}
opcodes::AUDIO_SOURCE => {
if payload.len() < 9 {
error!("Audio Source packet too short: {}", hex::encode(payload));
Expand Down
42 changes: 42 additions & 0 deletions linux-rust/src/devices/airpods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use ksni::Handle;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;
use tokio::time::{Duration, sleep};

Expand All @@ -24,6 +25,7 @@ impl AirPodsDevice {
mac_address: Address,
tray_handle: Option<Handle<MyTray>>,
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
stem_control: Arc<AtomicBool>,
) -> Self {
info!("Creating new AirPodsDevice for {}", mac_address);
let mut aacp_manager = AACPManager::new();
Expand Down Expand Up @@ -80,6 +82,20 @@ impl AirPodsDevice {
error!("Failed to request proximity keys: {}", e);
}

if stem_control.load(Ordering::Relaxed) {
// Enable stem press detection (double and triple tap)
// StemConfig bitmask for the control command: single=0x01, double=0x02, triple=0x04, long=0x08
// We want double and triple: 0x02 | 0x04 = 0x06
// Note: these bitmask values differ from the StemPressType event enum values (0x05–0x08)
info!("Enabling stem press detection for double and triple tap");
if let Err(e) = aacp_manager
.send_control_command(ControlCommandIdentifiers::StemConfig, &[0x06])
.await
{
error!("Failed to enable stem press detection: {}", e);
}
}

let session = bluer::Session::new()
.await
.expect("Failed to get bluer session");
Expand Down Expand Up @@ -206,6 +222,7 @@ impl AirPodsDevice {
let local_mac_events = local_mac.clone();
let ui_tx_clone = ui_tx.clone();
let command_tx_clone = command_tx.clone();
let stem_control_clone = stem_control.clone();
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let event_clone = event.clone();
Expand Down Expand Up @@ -325,6 +342,31 @@ impl AirPodsDevice {
controller.pause_all_media().await;
controller.deactivate_a2dp_profile().await;
}
AACPEvent::StemPress(press_type, bud_type) => {
use crate::bluetooth::aacp::StemPressType;
info!(
"Received Stem Press: {:?} on {:?}",
press_type, bud_type
);
if stem_control_clone.load(Ordering::Relaxed) {
let controller = mc_clone.lock().await;
match press_type {
StemPressType::DoublePress => {
info!("Double press detected, skipping to next track");
controller.next_track().await;
}
StemPressType::TriplePress => {
info!("Triple press detected, going to previous track");
controller.previous_track().await;
}
_ => {
debug!("Unhandled stem press type: {:?}", press_type);
}
}
} else {
debug!("Stem control disabled, ignoring stem press event");
}
}
_ => {
debug!("Received unhandled AACP event: {:?}", event);
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
Expand Down
84 changes: 57 additions & 27 deletions linux-rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers;
use crate::devices::enums::DeviceData;
use crate::ui::messages::BluetoothUIMessage;
use crate::ui::tray::MyTray;
use crate::utils::get_devices_path;
use crate::utils::{get_app_settings_path, get_devices_path};
use bluer::{Address, InternalErrorKind};
use clap::Parser;
use dbus::arg::{RefArg, Variant};
Expand All @@ -19,9 +19,10 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
use dbus::message::MatchRule;
use devices::airpods::AirPodsDevice;
use ksni::TrayMethods;
use log::info;
use log::{info, warn};
use std::collections::HashMap;
use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::mpsc::unbounded_channel;
Expand All @@ -44,6 +45,11 @@ struct Args {
le_debug: bool,
#[arg(long, short = 'v', help = "Show application version and exit")]
version: bool,
#[arg(
long,
help = "Disable stem press track control (use this if your environment already handles AirPods AVRCP commands natively)"
)]
no_stem_control: bool,
}

fn main() -> iced::Result {
Expand All @@ -59,10 +65,10 @@ fn main() -> iced::Result {

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() {
if wayland_display {
unsafe { env::set_var("WGPU_BACKEND", "gl") };
}
unsafe {
env::set_var(
"RUST_LOG",
Expand All @@ -80,19 +86,42 @@ fn main() -> iced::Result {

let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
Arc::new(RwLock::new(HashMap::new()));
let device_managers_clone = device_managers.clone();
std::thread::spawn(|| {

// Load stem_control initial value from settings JSON, then apply CLI override.
let app_settings_path = get_app_settings_path();
let saved_stem_control = std::fs::read_to_string(&app_settings_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("stem_control").and_then(|b| b.as_bool()))
.unwrap_or(true);
// CLI --no-stem-control overrides the saved setting.
let stem_control_initial = if args.no_stem_control { false } else { saved_stem_control };
let stem_control: Arc<AtomicBool> = Arc::new(AtomicBool::new(stem_control_initial));

if args.no_tray {
// Run headless without UI
info!("Running in headless mode (no GUI)");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers_clone))
.unwrap();
});
rt.block_on(async_main(ui_tx, device_managers, stem_control)).unwrap();
Ok(())
} else {
// Run with UI
let device_managers_clone = device_managers.clone();
let stem_control_clone = stem_control.clone();
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers_clone, stem_control_clone))
.unwrap();
});

ui::window::start_ui(ui_rx, args.start_minimized, device_managers)
ui::window::start_ui(ui_rx, args.start_minimized, device_managers, stem_control)
}
}

async fn async_main(
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
stem_control: Arc<AtomicBool>,
) -> bluer::Result<()> {
let args = Args::parse();

Expand Down Expand Up @@ -160,7 +189,7 @@ async fn async_main(
.unwrap_or_else(|| "Unknown".to_string());
info!("Found connected AirPods: {}, initializing.", name);
let airpods_device =
AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await;
AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone(), stem_control.clone()).await;

let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
Expand All @@ -170,11 +199,11 @@ async fn async_main(
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx
.send(BluetoothUIMessage::DeviceConnected(
device.address().to_string(),
))
.unwrap();
if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceConnected(
device.address().to_string(),
)) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
}
Err(_) => {
info!("No connected AirPods found.");
Expand Down Expand Up @@ -205,9 +234,9 @@ async fn async_main(
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_att(dev.att_manager);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str))
.unwrap();
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
}
drop(managers)
});
Expand Down Expand Up @@ -280,9 +309,9 @@ async fn async_main(
.or_insert(dev_managers)
.set_att(dev.att_manager);
drop(managers);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
.unwrap();
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
});
}
return true;
Expand All @@ -298,8 +327,9 @@ async fn async_main(
let handle_clone = tray_handle.clone();
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
let stem_control_arc = stem_control.clone();
tokio::spawn(async move {
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await;
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone(), stem_control_arc.clone()).await;
let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
Expand All @@ -308,9 +338,9 @@ async fn async_main(
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
.unwrap();
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
});
true
})?;
Expand Down
Loading