Skip to content
Open
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
112 changes: 111 additions & 1 deletion linux-rust/src/bluetooth/aacp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -295,6 +295,19 @@ pub struct ConnectedDevice {
pub r#type: Option<String>,
}

/// 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<BatteryInfo>),
Expand All @@ -306,6 +319,7 @@ pub enum AACPEvent {
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
OwnershipToFalseRequest,
StemPress(StemPressType, StemPressBudType),
HeadTracking(HeadTrackingData),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -361,16 +375,29 @@ impl AACPManagerState {
pub struct AACPManager {
pub state: Arc<Mutex<AACPManagerState>>,
tasks: Arc<Mutex<JoinSet<()>>>,
/// 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<std::sync::atomic::AtomicBool>,
}

impl AACPManager {
pub fn new() -> Self {
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);
Expand All @@ -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),
Expand Down Expand Up @@ -424,6 +474,13 @@ impl AACPManager {
}

info!("L2CAP connection established with {}", addr);
{
let sock_ref: &Socket<SeqPacket> = 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);

Expand Down Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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<SeqPacket>) {
Expand Down
71 changes: 71 additions & 0 deletions linux-rust/src/devices/airpods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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(),
Expand Down Expand Up @@ -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<std::time::Instant> = None;
while let Some(event) = rx.recv().await {
let event_clone = event.clone();
match event {
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions linux-rust/src/devices/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ pub struct AirPodsState {
pub personalized_volume_enabled: bool,
pub allow_off_mode: bool,
pub battery: Vec<BatteryInfo>,
// 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)]
Expand Down
Loading