From e082fe19da1fe861ed5eabc0671ad1fe1c8cb765 Mon Sep 17 00:00:00 2001 From: D <79015188+Dino-dev66@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:34:53 +0200 Subject: [PATCH] feat(linux-rust): AirPods Max support over encrypted AAP Force L2CAP security to Medium before connecting. AirPods refuse to emit the AAP notification stream over an unencrypted channel, so the Max only accepted writes (NC) and reported nothing back. With encryption it now streams battery, ear/on-head detection, listening mode, metadata and keys. What now works on AirPods Max: - Battery monitoring (single Headphone component, count=1) - Noise control set + live readback - On-head detection (drives media play/pause) - Connect/disconnect desktop notifications (notify-send) Spatial audio / head tracking: - Implemented the Pro-style 0x17 head-tracking stream: parse, start/stop senders, an AACPEvent, a live orientation visualiser, re-center, and nod/shake gesture detection mapped to media controls. - Gated off for AirPods Max (model A2096/A3184) with an explanatory note: the Max exposes motion via a ProtectedAccess HID-over-AAP relay that no host can open. Conversational Awareness is likewise unavailable (H1 chip). Co-Authored-By: Claude Opus 4.8 --- linux-rust/src/bluetooth/aacp.rs | 112 ++++++++++++- linux-rust/src/devices/airpods.rs | 71 +++++++++ linux-rust/src/devices/enums.rs | 26 +++ linux-rust/src/devices/gestures.rs | 110 +++++++++++++ linux-rust/src/devices/mod.rs | 1 + linux-rust/src/main.rs | 12 ++ linux-rust/src/media_controller.rs | 5 + linux-rust/src/ui/airpods.rs | 248 ++++++++++++++++++++++++++++- linux-rust/src/ui/tray.rs | 3 +- linux-rust/src/ui/window.rs | 17 ++ linux-rust/src/utils.rs | 28 ++++ 11 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 linux-rust/src/devices/gestures.rs diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index bed6ca853..c6ffe2b13 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -3,7 +3,7 @@ use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; use crate::utils::get_devices_path; use bluer::{ Address, AddressType, Error, Result, - l2cap::{SeqPacket, Socket, SocketAddr}, + l2cap::{SeqPacket, Security, SecurityLevel, Socket, SocketAddr}, }; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; @@ -295,6 +295,19 @@ pub struct ConnectedDevice { pub r#type: Option, } +/// Parsed head-tracking sensor sample from a 0x17 stream packet. +/// +/// `orientation*` are the three raw 16-bit orientation values (offsets 43/45/47); +/// `*_accel` are the raw acceleration values (offsets 51/53). All little-endian signed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HeadTrackingData { + pub orientation1: i16, + pub orientation2: i16, + pub orientation3: i16, + pub horizontal_accel: i16, + pub vertical_accel: i16, +} + #[derive(Debug, Clone)] pub enum AACPEvent { BatteryInfo(Vec), @@ -306,6 +319,7 @@ pub enum AACPEvent { ConnectedDevices(Vec, Vec), OwnershipToFalseRequest, StemPress(StemPressType, StemPressBudType), + HeadTracking(HeadTrackingData), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -361,6 +375,9 @@ impl AACPManagerState { pub struct AACPManager { pub state: Arc>, tasks: Arc>>, + /// Whether head-gesture detection should act on the head-tracking stream. + /// Shared so the UI (which holds an AACPManager clone) can toggle it live. + head_gestures: Arc, } impl AACPManager { @@ -368,9 +385,19 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), + head_gestures: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } + pub fn set_head_gestures_enabled(&self, enabled: bool) { + self.head_gestures + .store(enabled, std::sync::atomic::Ordering::Relaxed); + } + + pub fn head_gestures_enabled(&self) -> bool { + self.head_gestures.load(std::sync::atomic::Ordering::Relaxed) + } + pub async fn connect(&mut self, addr: Address) { info!("AACPManager connecting to {} on PSM {:#06X}...", addr, PSM); let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM); @@ -388,6 +415,29 @@ impl AACPManager { } }; + // Diagnostics: log the channel's default security and flow-control mode. + match socket.security() { + Ok(sec) => info!("L2CAP default security before connect: {:?}", sec), + Err(e) => info!("Could not read L2CAP security: {}", e), + } + match socket.flow_control() { + Ok(fc) => info!("L2CAP default flow control before connect: {:?}", fc), + Err(e) => info!("Could not read L2CAP flow control: {}", e), + } + + // AirPods refuse to emit the AAP notification stream over an unencrypted + // channel. bumble (proximity_keys.py) authenticates + encrypts before + // opening the channel; the equivalent on a raw BlueZ L2CAP socket is to + // request an authenticated, encrypted link via BT_SECURITY before connect. + if let Err(e) = socket.set_security(Security { + level: SecurityLevel::Medium, + key_size: 0, + }) { + error!("Failed to set L2CAP security to Medium: {}", e); + } else { + info!("Requested L2CAP security level Medium (encrypted) before connect"); + } + let seq_packet = match tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await { Ok(Ok(s)) => Arc::new(s), @@ -424,6 +474,13 @@ impl AACPManager { } info!("L2CAP connection established with {}", addr); + { + let sock_ref: &Socket = seq_packet.as_ref().as_ref(); + match sock_ref.security() { + Ok(sec) => info!("L2CAP security after connect: {:?}", sec), + Err(e) => info!("Could not read L2CAP security after connect: {}", e), + } + } let (tx, rx) = mpsc::channel(128); @@ -917,6 +974,40 @@ impl AACPManager { opcodes::EQ_DATA => { debug!("Received EQ Data"); } + opcodes::HEADTRACKING => { + // Two kinds of 0x17 packets arrive: + // - HID/service descriptor plists (variable layout, not sensor data) + // - orientation sensor stream: prefix 04 00 04 00 17 00 00 00 10 00, + // then packet[10] in {0x44, 0x45} and packet[11] == 0x00. + let is_sensor = packet.len() >= 55 + && packet[5] == 0x00 + && packet[6] == 0x00 + && packet[7] == 0x00 + && packet[8] == 0x10 + && packet[9] == 0x00 + && (packet[10] == 0x44 || packet[10] == 0x45) + && packet[11] == 0x00; + if is_sensor { + let le_i16 = |i: usize| i16::from_le_bytes([packet[i], packet[i + 1]]); + let data = HeadTrackingData { + orientation1: le_i16(43), + orientation2: le_i16(45), + orientation3: le_i16(47), + horizontal_accel: le_i16(51), + vertical_accel: le_i16(53), + }; + debug!("Received Head Tracking sample: {:?}", data); + let state = self.state.lock().await; + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::HeadTracking(data)); + } + } else { + debug!( + "Received Head Tracking descriptor/service packet ({} bytes)", + packet.len() + ); + } + } _ => debug!("Received unknown packet with opcode {:#04x}", opcode), } } @@ -1200,6 +1291,25 @@ impl AACPManager { self.send_data_packet(&[0x29, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) .await } + + /// Start the head-tracking / spatial sensor stream (opcode 0x17). + /// The accessory must own the connection for the stream to flow. + pub async fn send_start_head_tracking(&self) -> Result<()> { + self.send_data_packet(&[ + opcodes::HEADTRACKING, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1, 0x02, + 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C, 0x00, 0x00, + ]) + .await + } + + /// Stop the head-tracking / spatial sensor stream (opcode 0x17). + pub async fn send_stop_head_tracking(&self) -> Result<()> { + self.send_data_packet(&[ + opcodes::HEADTRACKING, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, + 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, + ]) + .await + } } async fn recv_thread(manager: AACPManager, sp: Arc) { diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..51428a192 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -105,6 +105,27 @@ impl AirPodsDevice { } } + // Opt-in head-tracking stream for testing (LIBREPODS_HEADTRACKING=1). + // Streams continuous orientation/acceleration samples once we own the connection. + if std::env::var("LIBREPODS_HEADTRACKING").as_deref() == Ok("1") { + let ht_manager = aacp_manager.clone(); + tokio::spawn(async move { + sleep(Duration::from_secs(3)).await; + info!("Taking ownership for head-tracking stream"); + if let Err(e) = ht_manager + .send_control_command(ControlCommandIdentifiers::OwnsConnection, &[0x01]) + .await + { + error!("Failed to take ownership: {}", e); + } + sleep(Duration::from_millis(500)).await; + info!("Starting head-tracking stream (LIBREPODS_HEADTRACKING=1)"); + if let Err(e) = ht_manager.send_start_head_tracking().await { + error!("Failed to start head tracking: {}", e); + } + }); + } + let session = bluer::Session::new() .await .expect("Failed to get bluer session"); @@ -118,6 +139,23 @@ impl AirPodsDevice { .expect("Failed to get adapter address") .to_string(); + // Notify that the AirPods connected, using their Bluetooth name. + let device_name = match adapter.device(mac_address) { + Ok(dev) => dev + .alias() + .await + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "AirPods".to_string()), + Err(_) => "AirPods".to_string(), + }; + crate::utils::notify( + &device_name, + "Connected", + "audio-headphones-symbolic", + "librepods-connection", + ); + let media_controller = Arc::new(Mutex::new(MediaController::new( mac_address.to_string(), local_mac.clone(), @@ -232,6 +270,8 @@ impl AirPodsDevice { let ui_tx_clone = ui_tx.clone(); let command_tx_clone = command_tx.clone(); tokio::spawn(async move { + let mut gesture_detector = crate::devices::gestures::GestureDetector::new(); + let mut last_ht_ui: Option = None; while let Some(event) = rx.recv().await { let event_clone = event.clone(); match event { @@ -375,6 +415,37 @@ impl AirPodsDevice { debug!("Stem control disabled, ignoring stem press event"); } } + AACPEvent::HeadTracking(data) => { + // Forward to the UI at ~10 Hz (the raw stream is much faster). + let now = std::time::Instant::now(); + let should_send = last_ht_ui + .map_or(true, |t| now.duration_since(t) >= Duration::from_millis(100)); + if should_send { + last_ht_ui = Some(now); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac_address.to_string(), + event_clone, + )); + } + // Run head-gesture detection when enabled (toggled from the UI). + if aacp_manager_clone_events.head_gestures_enabled() + && let Some(gesture) = + gesture_detector.push(data.horizontal_accel, data.vertical_accel) + { + use crate::devices::gestures::Gesture; + let controller = mc_clone.lock().await; + match gesture { + Gesture::Nod => { + info!("Head gesture: Nod -> play/pause"); + controller.play_pause().await; + } + Gesture::Shake => { + info!("Head gesture: Shake -> next track"); + controller.next_track().await; + } + } + } + } _ => { debug!("Received unhandled AACP event: {:?}", event); let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs index 5768d1802..1d0e13a7d 100644 --- a/linux-rust/src/devices/enums.rs +++ b/linux-rust/src/devices/enums.rs @@ -58,6 +58,32 @@ pub struct AirPodsState { pub personalized_volume_enabled: bool, pub allow_off_mode: bool, pub battery: Vec, + // Spatial audio / head tracking + pub head_tracking_enabled: bool, + pub head_gestures_enabled: bool, + /// Latest raw head-tracking sample: (orientation1, orientation2, orientation3, + /// horizontal_accel, vertical_accel). + pub head_tracking_sample: Option<(i16, i16, i16, i16, i16)>, + /// Calibration baseline (orientation1, orientation2, orientation3) for re-centering. + pub head_tracking_neutral: Option<(i16, i16, i16)>, +} + +impl AirPodsState { + /// Calibrated (pitch, yaw, roll) in degrees from the latest sample, relative + /// to the neutral baseline. Returns None if no sample yet. + pub fn head_orientation_degrees(&self) -> Option<(f32, f32, f32)> { + let (o1, o2, o3, _, _) = self.head_tracking_sample?; + let (n1, n2, n3) = self.head_tracking_neutral.unwrap_or((0, 0, 0)); + let o1n = (o1 - n1) as f32; + let o2n = (o2 - n2) as f32; + let o3n = (o3 - n3) as f32; + // Matches the head-tracking reference: orientation pair maps to pitch/yaw + // over a ~+-32000 range scaled to +-180 degrees; orientation1 ~ roll/twist. + let pitch = (o2n + o3n) / 2.0 / 32000.0 * 180.0; + let yaw = (o2n - o3n) / 2.0 / 32000.0 * 180.0; + let roll = o1n / 32000.0 * 180.0; + Some((pitch, yaw, roll)) + } } #[derive(Clone, Debug)] diff --git a/linux-rust/src/devices/gestures.rs b/linux-rust/src/devices/gestures.rs new file mode 100644 index 000000000..e9257cf0e --- /dev/null +++ b/linux-rust/src/devices/gestures.rs @@ -0,0 +1,110 @@ +//! Head-gesture detection from the AirPods head-tracking acceleration stream. +//! +//! Operates on the horizontal and vertical acceleration values (offsets 51/53 +//! of the 0x17 sensor packet). A nod ("yes") is a vertical oscillation; a shake +//! ("no") is a horizontal oscillation. The detector looks for several large +//! threshold crossings in one axis within a short window, with that axis clearly +//! dominating the other, then enforces a cooldown to avoid repeat triggers. + +use std::collections::VecDeque; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Gesture { + /// Nodding up/down — typically mapped to play/pause ("yes"). + Nod, + /// Shaking left/right — typically mapped to next track ("no"). + Shake, +} + +const WINDOW: usize = 25; // ~0.8s at the ~30 Hz sample rate +const CROSS_THRESHOLD: f32 = 300.0; // magnitude a swing must exceed to count +const MIN_CROSSINGS: u32 = 3; // distinct large swings needed for an oscillation +const DOMINANCE: f32 = 1.8; // active axis peak-to-peak must beat the other by this +const COOLDOWN_SAMPLES: u32 = 30; // ignore input briefly after a detection + +pub struct GestureDetector { + vertical: VecDeque, + horizontal: VecDeque, + cooldown: u32, +} + +impl GestureDetector { + pub fn new() -> Self { + Self { + vertical: VecDeque::with_capacity(WINDOW), + horizontal: VecDeque::with_capacity(WINDOW), + cooldown: 0, + } + } + + pub fn push(&mut self, horizontal: i16, vertical: i16) -> Option { + push_capped(&mut self.horizontal, horizontal as f32); + push_capped(&mut self.vertical, vertical as f32); + + if self.cooldown > 0 { + self.cooldown -= 1; + return None; + } + if self.vertical.len() < WINDOW { + return None; + } + + let (v_cross, v_pp) = analyze(&self.vertical); + let (h_cross, h_pp) = analyze(&self.horizontal); + + // Nod: vertical oscillation dominating the horizontal axis. + if v_cross >= MIN_CROSSINGS && v_pp > h_pp * DOMINANCE { + self.reset(); + return Some(Gesture::Nod); + } + // Shake: horizontal oscillation dominating the vertical axis. + if h_cross >= MIN_CROSSINGS && h_pp > v_pp * DOMINANCE { + self.reset(); + return Some(Gesture::Shake); + } + None + } + + fn reset(&mut self) { + self.vertical.clear(); + self.horizontal.clear(); + self.cooldown = COOLDOWN_SAMPLES; + } +} + +fn push_capped(buf: &mut VecDeque, v: f32) { + if buf.len() == WINDOW { + buf.pop_front(); + } + buf.push_back(v); +} + +/// Returns (number of large threshold crossings, peak-to-peak amplitude). +fn analyze(buf: &VecDeque) -> (u32, f32) { + let mut min = f32::MAX; + let mut max = f32::MIN; + // Count sign changes of the signal where the swing exceeds the threshold: + // an oscillation alternates above +T and below -T. + let mut crossings = 0u32; + let mut last_sign = 0i8; + for &v in buf { + if v < min { + min = v; + } + if v > max { + max = v; + } + let sign = if v > CROSS_THRESHOLD { + 1 + } else if v < -CROSS_THRESHOLD { + -1 + } else { + 0 + }; + if sign != 0 && sign != last_sign { + crossings += 1; + last_sign = sign; + } + } + (crossings, max - min) +} diff --git a/linux-rust/src/devices/mod.rs b/linux-rust/src/devices/mod.rs index c5d459f7c..f3e4762bb 100644 --- a/linux-rust/src/devices/mod.rs +++ b/linux-rust/src/devices/mod.rs @@ -1,3 +1,4 @@ pub mod airpods; pub mod enums; +pub mod gestures; pub(crate) mod nothing; diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..31fa42e91 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -274,6 +274,18 @@ async fn async_main( return true; }; if is_connected==0 { + // Notify on AirPods disconnect (only for our AirPods, identified by UUID). + if uuids.iter().any(|u| u.to_lowercase() == target_uuid) { + let name = proxy + .get::("org.bluez.Device1", "Name") + .unwrap_or_else(|_| "AirPods".to_string()); + crate::utils::notify( + &name, + "Disconnected", + "audio-headphones-symbolic", + "librepods-connection", + ); + } if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceDisconnected(addr_str.clone())) { warn!("Failed to send DeviceConnected UI message: {:?}", e); } diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..28ce7f320 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -522,6 +522,11 @@ impl MediaController { self.mpris_command("Previous").await; } + pub async fn play_pause(&self) { + info!("Toggling play/pause"); + self.mpris_command("PlayPause").await; + } + async fn is_a2dp_profile_available(&self) -> bool { let index = match self.state.lock().await.device_index { Some(i) => i, diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 9335b5e05..477fcf315 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -5,7 +5,8 @@ use iced::overlay::menu; use iced::widget::button::Style; use iced::widget::rule::FillMode; use iced::widget::{ - Space, button, column, combo_box, container, row, rule, text, text_input, toggler, + Space, button, column, combo_box, container, progress_bar, row, rule, scrollable, text, + text_input, toggler, }; use iced::{Background, Border, Center, Color, Length, Padding, Theme}; use log::error; @@ -362,6 +363,69 @@ pub fn airpods_view<'a>( ) }; + // ----- Spatial Audio / Head Tracking ----- + // AirPods Max gate motion data behind a ProtectedAccess HID relay that no + // host can open without Apple's authentication, so head tracking only works + // on models that use the Pro-style 0x17 stream. + let is_airpods_max = devices_list + .get(mac_information.as_str()) + .and_then(|d| d.information.as_ref()) + .and_then(|info| match info { + DeviceInformation::AirPods(a) => Some(a.model_number.clone()), + _ => None, + }) + .map(|m| matches!(m.as_str(), "A2096" | "A3184")) + .unwrap_or(false); + + let spatial_header = container(text("Spatial Audio").size(18).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + })) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }); + + let spatial_audio_col = if is_airpods_max { + column![ + spatial_header, + container( + column![ + text("Head tracking is not available on AirPods Max.").size(15), + text("Apple gates the Max's motion sensor behind an authenticated (ProtectedAccess) HID service, so head orientation can't be read on Linux. Head tracking works on AirPods Pro.") + .size(12) + .style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }), + ] + .spacing(6) + .padding(8) + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }) + ] + .spacing(12) + } else { + spatial_audio_controls(&mac, state, aacp_manager.clone(), spatial_header) + }; + let mut information_col = column![]; if let Some(device) = devices_list.get(mac_information.as_str()) { if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { @@ -507,22 +571,200 @@ pub fn airpods_view<'a>( } } - container(column![ + container(scrollable(column![ rename_input, Space::new().height(Length::from(20)), listening_mode, Space::new().height(Length::from(20)), audio_settings_col, Space::new().height(Length::from(20)), + spatial_audio_col, + Space::new().height(Length::from(20)), off_listening_mode_toggle, Space::new().height(Length::from(20)), information_col - ]) + ])) .padding(20) .center_x(Length::Fill) .height(Length::Fill) } +/// Builds the interactive Spatial Audio / head-tracking controls (for models +/// that support the Pro-style head-tracking stream). +fn spatial_audio_controls( + mac: &str, + state: &AirPodsState, + aacp_manager: Arc, + header: iced::widget::Container<'static, Message>, +) -> iced::widget::Column<'static, Message> { + let (ht_pitch, ht_yaw, ht_roll) = + state.head_orientation_degrees().unwrap_or((0.0, 0.0, 0.0)); + + let axis = |label: &'static str, value: f32| -> iced::Element<'static, Message> { + let v = value.clamp(-90.0, 90.0); + row![ + text(label).size(14).width(Length::from(55)), + progress_bar(-90.0..=90.0, v) + .length(Length::Fill) + .girth(Length::from(10)), + text(format!("{:+.0}\u{00B0}", value)) + .size(14) + .width(Length::from(50)), + ] + .align_y(Center) + .spacing(10) + .into() + }; + + let recenter_msg = { + let mut s = state.clone(); + s.head_tracking_neutral = s.head_tracking_sample.map(|(o1, o2, o3, _, _)| (o1, o2, o3)); + Message::StateChanged(mac.to_string(), DeviceState::AirPods(s)) + }; + + let aacp_manager_ht = aacp_manager.clone(); + let head_tracking_toggle = { + let mac = mac.to_string(); + let state = state.clone(); + row![ + column![ + text("Head Tracking").size(16), + text("Streams live head orientation from the AirPods motion sensors. Required for gestures and spatial audio.") + .size(12) + .style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }) + .width(Length::Fill), + ] + .width(Length::Fill), + toggler(state.head_tracking_enabled) + .on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_ht.clone(); + run_async_in_thread(async move { + if is_enabled { + let _ = aacp_manager + .send_control_command(ControlCommandIdentifiers::OwnsConnection, &[0x01]) + .await; + let _ = aacp_manager.send_start_head_tracking().await; + } else { + let _ = aacp_manager.send_stop_head_tracking().await; + } + }); + let mut state = state.clone(); + state.head_tracking_enabled = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20), + ] + .align_y(Center) + .spacing(8) + }; + + let aacp_manager_hg = aacp_manager.clone(); + let head_gestures_toggle = { + let mac = mac.to_string(); + let state = state.clone(); + row![ + column![ + text("Head Gestures").size(16), + text("Nod to play/pause, shake to skip to the next track. (Experimental.)") + .size(12) + .style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }) + .width(Length::Fill), + ] + .width(Length::Fill), + toggler(state.head_gestures_enabled) + .on_toggle(move |is_enabled| { + aacp_manager_hg.set_head_gestures_enabled(is_enabled); + let mut state = state.clone(); + state.head_gestures_enabled = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20), + ] + .align_y(Center) + .spacing(8) + }; + + let live_label = if state.head_tracking_sample.is_some() { + "Live orientation" + } else { + "Live orientation (enable Head Tracking and move your head)" + }; + + column![ + header, + container( + column![ + head_tracking_toggle, + rule::horizontal(1).style(|theme: &Theme| rule::Style { + color: theme.palette().text.scale_alpha(0.2), + radius: Radius::from(12), + fill_mode: FillMode::Full, + snap: false + }), + column![ + text(live_label).size(13).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }), + axis("Pitch", ht_pitch), + axis("Yaw", ht_yaw), + axis("Roll", ht_roll), + container( + button(text("Re-center").size(14)) + .on_press(recenter_msg) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.3))); + style.border = Border::default().rounded(8.0); + style + }) + .padding(8) + ) + .align_x(End), + ] + .spacing(8), + rule::horizontal(1).style(|theme: &Theme| rule::Style { + color: theme.palette().text.scale_alpha(0.2), + radius: Radius::from(12), + fill_mode: FillMode::Full, + snap: false + }), + head_gestures_toggle, + ] + .spacing(8) + .padding(8) + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }) + ] + .spacing(12) +} + fn run_async_in_thread(fut: F) where F: Future + Send + 'static, diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b3adbc53a..777ed6948 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -65,7 +65,8 @@ impl ksni::Tray for MyTray { } }; let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging)) - || matches!(self.battery_r_status, Some(BatteryStatus::Charging)); + || matches!(self.battery_r_status, Some(BatteryStatus::Charging)) + || matches!(self.battery_headphone_status, Some(BatteryStatus::Charging)); let app_settings_path = get_app_settings_path(); let settings = std::fs::read_to_string(&app_settings_path) .ok() diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..6b9d238f0 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -382,6 +382,10 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), + head_tracking_enabled: false, + head_gestures_enabled: false, + head_tracking_sample: None, + head_tracking_neutral: None, })); } Some(DeviceType::Nothing) => { @@ -517,6 +521,19 @@ impl App { debug!("Updated battery info for {}: {:?}", mac, state.battery); } } + AACPEvent::HeadTracking(data) => { + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.head_tracking_sample = Some(( + data.orientation1, + data.orientation2, + data.orientation3, + data.horizontal_accel, + data.vertical_accel, + )); + } + } _ => {} } Task::batch(vec![wait_task]) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..c6c1b7ed1 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -51,6 +51,34 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +/// Show a transient desktop notification via `notify-send` (best-effort). +/// +/// `tag` is used as a synchronous hint so repeated notifications of the same +/// kind (e.g. noise-control changes) replace each other in place instead of +/// stacking. Spawns and does not wait; failures are logged, never fatal. +pub fn notify(summary: &str, body: &str, icon: &str, tag: &str) { + let mut cmd = std::process::Command::new("notify-send"); + cmd.arg("--app-name=LibrePods") + .arg("--expire-time=1500") + // transient: GNOME shows the banner briefly and does not keep it in the + // notification list (GNOME ignores expire-time for the banner itself). + .arg("--hint=int:transient:1") + // synchronous: repeated notifications of the same kind replace in place. + .arg(format!("--hint=string:x-canonical-private-synchronous:{tag}")) + .arg("--hint=string:synchronous:".to_string() + tag) + .arg("--icon") + .arg(icon) + .arg(summary); + if !body.is_empty() { + cmd.arg(body); + } + cmd.stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Err(e) = cmd.spawn() { + log::warn!("Failed to send notification via notify-send: {}", e); + } +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse();