diff --git a/crossplatform/windows/daemon/src/driver.rs b/crossplatform/windows/daemon/src/driver.rs index d22557b25..2bec43b8d 100644 --- a/crossplatform/windows/daemon/src/driver.rs +++ b/crossplatform/windows/daemon/src/driver.rs @@ -32,6 +32,8 @@ const IOCTL_LP_CONNECT: u32 = 0x8000_2000; const IOCTL_LP_SEND: u32 = 0x8000_2008; const IOCTL_LP_RECEIVE: u32 = 0x8000_200C; const IOCTL_LP_GET_STATUS: u32 = 0x8000_2010; +const IOCTL_LP_ATT_SEND: u32 = 0x8000_2014; +const IOCTL_LP_ATT_RECEIVE: u32 = 0x8000_2018; struct DriverHandle(HANDLE); unsafe impl Send for DriverHandle {} @@ -98,13 +100,41 @@ impl Driver { Ok(ioctl(self.handle.0, IOCTL_LP_RECEIVE, &to, buf)? as usize) } + /// Send a raw ATT PDU over the ATT (PSM 0x001F) hearing-aid channel. + pub fn att_send(&self, data: &[u8]) -> io::Result<()> { + ioctl(self.handle.0, IOCTL_LP_ATT_SEND, data, &mut [])?; + Ok(()) + } + + /// Receive a raw ATT PDU from the ATT channel (blocking up to timeout_ms). + pub fn att_recv(&self, timeout_ms: u32, buf: &mut [u8]) -> io::Result { + let to = timeout_ms.to_le_bytes(); + Ok(ioctl(self.handle.0, IOCTL_LP_ATT_RECEIVE, &to, buf)? as usize) + } + /// Driver connection state (2 = connected). Reads a state variable only — /// no L2CAP I/O, so it never disturbs the audio link. pub fn status(&self) -> io::Result { - let mut out = [0u8; 12]; + let mut out = [0u8; 32]; ioctl(self.handle.0, IOCTL_LP_GET_STATUS, &[], &mut out)?; Ok(u32::from_le_bytes([out[0], out[1], out[2], out[3]])) } + + /// ATT (PSM 0x001F) hearing-aid server diagnostics from the driver: + /// (register_ntstatus, server_registered, connect_indications, accept_ntstatus, + /// channel_open). Lets us see the hearing-aid channel progress in the daemon log + /// without a kernel debugger. + pub fn att_diag(&self) -> io::Result<(i32, u32, u32, i32, u32)> { + let mut out = [0u8; 32]; + ioctl(self.handle.0, IOCTL_LP_GET_STATUS, &[], &mut out)?; + Ok(( + i32::from_le_bytes([out[28], out[29], out[30], out[31]]), // register status + u32::from_le_bytes([out[12], out[13], out[14], out[15]]), // registered 0/1 + u32::from_le_bytes([out[16], out[17], out[18], out[19]]), // indications + i32::from_le_bytes([out[20], out[21], out[22], out[23]]), // accept status + u32::from_le_bytes([out[24], out[25], out[26], out[27]]), // channel open + )) + } } fn open_driver() -> io::Result { diff --git a/crossplatform/windows/daemon/src/hearing.rs b/crossplatform/windows/daemon/src/hearing.rs new file mode 100644 index 000000000..a1f74bb76 --- /dev/null +++ b/crossplatform/windows/daemon/src/hearing.rs @@ -0,0 +1,121 @@ +//! AirPods Pro 3 hearing assistance: enable it over AAP (control commands 0x2C / +//! 0x33), then write the amplification settings to the ATT/GATT (PSM 0x001F, handle +//! 0x2A) via a read-modify-write. The layout mirrors the Android client +//! (HearingAidEnums): 8-band EQ per ear + per-ear amplification/tone/conversation- +//! boost as little-endian f32. We leave the audiogram EQ bands untouched for now and +//! drive only the overall amplification / balance / conversation boost. + +use crate::aap; +use crate::driver::Driver; +use std::{thread, time::Duration}; + +// AAP hearing-assist enable/disable (0x09 control commands 0x2C / 0x33). +const HA_ON_2C: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x00]; +const HA_ON_33: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x01, 0x00, 0x00, 0x00]; +const HA_OFF_2C: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x02, 0x00, 0x00]; +const HA_OFF_33: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x02, 0x00, 0x00, 0x00]; + +const H_SETTINGS: u16 = 0x002A; // hearing-aid settings characteristic +const H_CCCD: u16 = 0x002B; // its client-config descriptor + +// f32 offsets into the settings value (bytes after the ATT opcode). +const OFF_MODE: usize = 2; +const OFF_LEFT_AMP: usize = 36; +const OFF_LEFT_TONE: usize = 40; +const OFF_LEFT_CONV: usize = 44; +const OFF_RIGHT_AMP: usize = 84; +const OFF_RIGHT_TONE: usize = 88; +const OFF_RIGHT_CONV: usize = 92; + +fn put_f32(buf: &mut [u8], off: usize, v: f32) { + if off + 4 <= buf.len() { + buf[off..off + 4].copy_from_slice(&v.to_le_bytes()); + } +} + +fn att_read_req(handle: u16) -> [u8; 3] { + [0x0A, (handle & 0xff) as u8, (handle >> 8) as u8] +} + +fn att_write_pdu(handle: u16, value: &[u8]) -> Vec { + let mut p = vec![0x12u8, (handle & 0xff) as u8, (handle >> 8) as u8]; + p.extend_from_slice(value); + p +} + +/// Apply hearing-assist settings. Requires the AAP + ATT channels to be up (the +/// driver opens ATT on connect). Returns a short summary for the daemon log. +pub fn apply( + drv: &Driver, + on: bool, + amplification: f32, + balance: f32, + conv_boost: bool, +) -> Result { + if !on { + let _ = drv.send(&HA_OFF_33); + thread::sleep(Duration::from_millis(300)); + let _ = drv.send(&HA_OFF_2C); + return Ok("hearing aid OFF".into()); + } + + // 1) Wake the buds' hearing-aid ATT server (it is dormant until enabled), and + // switch to Transparency (mode 3) so ambient sound passes through to be + // amplified — in ANC/Off there is nothing to amplify. + let _ = drv.send(&HA_ON_2C); + thread::sleep(Duration::from_millis(300)); + let _ = drv.send(&aap::anc_command(3)); + thread::sleep(Duration::from_millis(200)); + let _ = drv.send(&HA_ON_33); + thread::sleep(Duration::from_millis(900)); + + // 2) Enable notifications on the settings CCCD. + let mut b = [0u8; 512]; + let _ = drv.att_send(&att_write_pdu(H_CCCD, &[0x01, 0x00])); + let _ = drv.att_recv(2000, &mut b); + + // 3) Read the current settings value (read-modify-write). + let _ = drv.att_send(&att_read_req(H_SETTINGS)); + let n = drv + .att_recv(2000, &mut b) + .map_err(|e| format!("ATT read err: {e}"))?; + if n < 8 || b[0] != 0x0B { + return Err(format!("bad ATT read resp [{n}]")); + } + let mut val = b[1..n].to_vec(); // the characteristic value (~104 bytes) + + // 4) Patch amplification / balance / conversation boost. Audiogram EQ untouched. + let amp = amplification.clamp(0.0, 1.0); + let bal = balance.clamp(-1.0, 1.0); + let left_amp = (amp + if bal < 0.0 { -bal } else { 0.0 }).clamp(0.0, 1.0); + let right_amp = (amp + if bal > 0.0 { bal } else { 0.0 }).clamp(0.0, 1.0); + let cb = if conv_boost { 1.0f32 } else { 0.0f32 }; + if val.len() > OFF_MODE { + val[OFF_MODE] = 0x64; + } + // Flat audiogram: a broadband gain across all 8 EQ bands per ear. A zero + // audiogram leaves the amplification nothing to scale (you'd hear nothing), so + // we synthesize a flat boost from the slider. BAND_GAIN is a first guess at the + // units (dB-ish) — tune against hardware. + const BAND_GAIN: f32 = 30.0; + for i in 0..8usize { + put_f32(&mut val, 4 + i * 4, left_amp * BAND_GAIN); + put_f32(&mut val, 52 + i * 4, right_amp * BAND_GAIN); + } + put_f32(&mut val, OFF_LEFT_AMP, left_amp); + put_f32(&mut val, OFF_LEFT_TONE, 0.0); + put_f32(&mut val, OFF_LEFT_CONV, cb); + put_f32(&mut val, OFF_RIGHT_AMP, right_amp); + put_f32(&mut val, OFF_RIGHT_TONE, 0.0); + put_f32(&mut val, OFF_RIGHT_CONV, cb); + + // 5) Write it back. + let _ = drv.att_send(&att_write_pdu(H_SETTINGS, &val)); + let wn = drv.att_recv(2000, &mut b).unwrap_or(0); + let wr = if wn >= 1 && b[0] == 0x13 { "ok" } else { "no-resp" }; + + Ok(format!( + "hearing aid ON: wrote {} bytes leftAmp={left_amp:.2} rightAmp={right_amp:.2} conv={conv_boost} write={wr}", + val.len() + )) +} diff --git a/crossplatform/windows/daemon/src/main.rs b/crossplatform/windows/daemon/src/main.rs index 8464081fc..e1473e56e 100644 --- a/crossplatform/windows/daemon/src/main.rs +++ b/crossplatform/windows/daemon/src/main.rs @@ -11,6 +11,7 @@ mod aap; mod bt; mod driver; mod eld; +mod hearing; mod hr; mod le; mod media; @@ -745,6 +746,20 @@ fn apply_command(ctx: &Ctx, cmd: Command) { ctx.sync_volume(); } Command::SetHeartRate { on } => set_heart_rate(ctx, on), + Command::SetHearingAid { on, amplification, balance, conversation_boost } => { + // Runs on its own thread — hearing::apply has ~1.3 s of enable settle + // sleeps + ATT round-trips and must not block the command pump. + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let ctx2 = ctx.clone(); + thread::spawn(move || match hearing::apply(&drv, on, amplification, balance, conversation_boost) { + Ok(s) => { + log(&s); + ctx2.overlay(if on { "Hearing aid on" } else { "Hearing aid off" }); + } + Err(e) => log(&format!("hearing aid FAILED: {e}")), + }); + } + } Command::Connect => { // The user accepted the prompt — let the session start, and ask the OS // to (re)connect the audio in case the device was BT-disconnected. @@ -893,7 +908,25 @@ fn run_receiver(ctx: Ctx) { // ears) and on a real disconnect (cased / on the phone) — status alone // can't tell them apart, so data flow is the tie-breaker. let mut last_data = Instant::now(); + // ATT (PSM 0x001F) hearing-aid server diagnostics, polled from the driver + // and logged on change (DebugView never showed the driver's KdPrint). + let mut att_poll = Instant::now(); + let mut last_att: (i32, u32, u32, i32, u32) = (0, 0, 0, 0, 0); loop { + if att_poll.elapsed() >= Duration::from_millis(1500) { + att_poll = Instant::now(); + if let Ok(d) = driver.att_diag() { + if d != last_att { + let was_open = last_att.4; + last_att = d; + let _ = was_open; + log(&format!( + "ATT: register=0x{:08X} registered={} indications={} accept=0x{:08X} channel_open={}", + d.0 as u32, d.1, d.2, d.3 as u32, d.4 + )); + } + } + } // The user pressed Disconnect (connect_requested cleared) — release. if !ctx.connect_requested.load(Ordering::Relaxed) { log("run_receiver: disconnect requested — releasing"); diff --git a/crossplatform/windows/drivers/aap/Device.c b/crossplatform/windows/drivers/aap/Device.c index 4693825d9..ae730472b 100755 --- a/crossplatform/windows/drivers/aap/Device.c +++ b/crossplatform/windows/drivers/aap/Device.c @@ -46,6 +46,11 @@ LpEvtDevicePrepareHardware( ctx->HasBthInterface = TRUE; KdPrint(("LibrePodsAAP: acquired BTH profile interface\n")); + + // NB: the ATT (PSM 0x001F) server is registered later, from LpConnect, once we + // know the AirPods' address (registering with BtAddress=0 here returned + // STATUS_INVALID_PARAMETER 0xC000000D). + return STATUS_SUCCESS; } @@ -62,6 +67,11 @@ LpEvtDeviceReleaseHardware( LpDisconnect(ctx); } + // Close the accepted ATT channel, then unregister the server, before dropping + // the interface (both use it). + LpCloseAttChannel(ctx); + LpUnregisterAttServer(ctx); + // Release the Bluetooth profile driver interface we took in // PrepareHardware. WdfFdoQueryForInterface increments the interface's // reference count; not dereferencing it leaks a reference to our BTHENUM diff --git a/crossplatform/windows/drivers/aap/Driver.c b/crossplatform/windows/drivers/aap/Driver.c index c3c54c14f..a7dfada23 100755 --- a/crossplatform/windows/drivers/aap/Driver.c +++ b/crossplatform/windows/drivers/aap/Driver.c @@ -83,6 +83,8 @@ LpEvtDeviceAdd( ctx = DeviceGetContext(device); RtlZeroMemory(ctx, sizeof(*ctx)); ctx->State = LpDisconnected; + ctx->AttAcceptStatus = STATUS_PENDING; // 0x00000103 = accept not yet attempted + ctx->AttRegisterStatus = STATUS_PENDING; // 0x00000103 = register not yet attempted ctx->WdmDeviceObject = WdfDeviceWdmGetDeviceObject(device); status = WdfSpinLockCreate(WDF_NO_OBJECT_ATTRIBUTES, &ctx->Lock); @@ -91,6 +93,21 @@ LpEvtDeviceAdd( return status; } + // Work item that accepts the AirPods' inbound ATT (PSM 0x001F) connection at + // PASSIVE_LEVEL (the connect indication may run at DISPATCH_LEVEL). + { + WDF_WORKITEM_CONFIG wiConfig; + WDF_OBJECT_ATTRIBUTES wiAttrs; + WDF_WORKITEM_CONFIG_INIT(&wiConfig, LpAttAcceptWorkItem); + WDF_OBJECT_ATTRIBUTES_INIT(&wiAttrs); + wiAttrs.ParentObject = device; + status = WdfWorkItemCreate(&wiConfig, &wiAttrs, &ctx->AttAcceptWorkItem); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfWorkItemCreate failed 0x%08X\n", status)); + return status; + } + } + // Single sequential IOCTL queue (connect/send/receive are serialized). WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchSequential); queueConfig.EvtIoDeviceControl = LpEvtIoDeviceControl; diff --git a/crossplatform/windows/drivers/aap/Ioctl.c b/crossplatform/windows/drivers/aap/Ioctl.c index 72ac04149..f6cf427a7 100755 --- a/crossplatform/windows/drivers/aap/Ioctl.c +++ b/crossplatform/windows/drivers/aap/Ioctl.c @@ -114,10 +114,50 @@ LpEvtIoDeviceControl( status = WdfRequestRetrieveOutputBuffer(Request, sizeof(LP_STATUS_OUTPUT), &outBuf, &sz); if (!NT_SUCCESS(status)) break; - out = (PLP_STATUS_OUTPUT)outBuf; - out->State = (ULONG)ctx->State; - out->ConnectedAddress = ctx->RemoteAddress; - information = sizeof(LP_STATUS_OUTPUT); + out = (PLP_STATUS_OUTPUT)outBuf; + out->State = (ULONG)ctx->State; + out->ConnectedAddress = ctx->RemoteAddress; + out->AttServerRegistered = ctx->AttServerRegistered ? 1u : 0u; + out->AttIndicationCount = ctx->AttIndicationCount; + out->AttAcceptStatus = ctx->AttAcceptStatus; + out->AttChannelOpen = ctx->AttConnected ? 1u : 0u; + out->AttRegisterStatus = ctx->AttRegisterStatus; + information = sizeof(LP_STATUS_OUTPUT); + break; + } + + case IOCTL_LP_ATT_SEND: { + if (InputBufferLength == 0) { + status = STATUS_INVALID_PARAMETER; + break; + } + status = WdfRequestRetrieveInputBuffer(Request, 1, &inBuf, &sz); + if (!NT_SUCCESS(status)) break; + status = LpAttSend(ctx, inBuf, (ULONG)sz); + break; + } + + case IOCTL_LP_ATT_RECEIVE: { + ULONG timeoutMs = 0; + ULONG bytesRead = 0; + + if (InputBufferLength >= sizeof(LP_RECEIVE_INPUT)) { + status = WdfRequestRetrieveInputBuffer(Request, sizeof(LP_RECEIVE_INPUT), &inBuf, &sz); + if (NT_SUCCESS(status)) { + timeoutMs = ((PLP_RECEIVE_INPUT)inBuf)->TimeoutMs; + } + } + if (OutputBufferLength == 0) { + status = STATUS_BUFFER_TOO_SMALL; + break; + } + status = WdfRequestRetrieveOutputBuffer(Request, 1, &outBuf, &sz); + if (!NT_SUCCESS(status)) break; + + status = LpAttReceive(ctx, outBuf, (ULONG)sz, &bytesRead, timeoutMs); + if (NT_SUCCESS(status)) { + information = bytesRead; + } break; } diff --git a/crossplatform/windows/drivers/aap/L2cap.c b/crossplatform/windows/drivers/aap/L2cap.c index 46d7fb2b8..4a8f6ed7c 100755 --- a/crossplatform/windows/drivers/aap/L2cap.c +++ b/crossplatform/windows/drivers/aap/L2cap.c @@ -128,6 +128,11 @@ LpConnect( Ctx->State = LpConnected; WdfSpinLockRelease(Ctx->Lock); KdPrint(("LibrePodsAAP: L2CAP connected (handle=%p)\n", brb->ChannelHandle)); + // Open the ATT (PSM 0x001F) channel to the AirPods as a client (like + // Android's ATTManager) for the hearing-aid config. Best-effort; if the + // buds' ATT server isn't up yet (may need hearing-assist enabled first) the + // open just fails and we retry later. The AAP channel works regardless. + (VOID)LpConnectAtt(Ctx); } else { WdfSpinLockAcquire(Ctx->Lock); Ctx->State = LpDisconnected; @@ -140,6 +145,150 @@ LpConnect( return status; } +// +// Open the ATT (PSM 0x001F) channel to the AirPods as a CLIENT — the same +// outbound BRB_L2CA_OPEN_CHANNEL we use for the AAP channel (0x1001), just a second +// channel on the reserved ATT PSM. This is exactly what Android's ATTManager does +// (createL2capChannel(0x1F)); connecting as a client to a reserved PSM is allowed, +// unlike registering a SERVER on it. The buds accept it (their end is the ATT +// server) and we then read/write the hearing-aid audiogram over handle 0x2A. +// +NTSTATUS +LpConnectAtt( + _In_ PDEVICE_CONTEXT Ctx +) +{ + NTSTATUS status; + struct _BRB_L2CA_OPEN_CHANNEL* brb; + + if (!Ctx->HasBthInterface) { + return STATUS_DEVICE_NOT_READY; + } + if (Ctx->AttConnected) { + return STATUS_SUCCESS; + } + + brb = (struct _BRB_L2CA_OPEN_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_OPEN_CHANNEL, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->Psm = PSM_ATT; // 0x001F, connect to the buds' ATT server + brb->ChannelFlags = CF_ROLE_EITHER; + brb->ConfigOut.Flags = 0; + brb->ConfigIn.Flags = 0; + brb->IncomingQueueDepth = 10; + brb->CallbackFlags = CALLBACK_DISCONNECT; + brb->Callback = LpAttServerIndication; // reused for the disconnect event + brb->CallbackContext = Ctx; + brb->ReferenceObject = Ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + WdfSpinLockAcquire(Ctx->Lock); + Ctx->AttAcceptStatus = status; // reuse the accept-status field for the open result + if (NT_SUCCESS(status)) { + Ctx->AttChannelHandle = brb->ChannelHandle; + Ctx->AttConnected = TRUE; + } + WdfSpinLockRelease(Ctx->Lock); + if (NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: *** ATT client channel OPEN (handle=%p) ***\n", brb->ChannelHandle)); + } else { + KdPrint(("LibrePodsAAP: ATT client open FAILED 0x%08X\n", status)); + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Write raw ATT PDU bytes to the ATT (PSM 0x001F) channel. +// +NTSTATUS +LpAttSend( + _In_ PDEVICE_CONTEXT Ctx, + _In_ PVOID Buffer, + _In_ ULONG Length +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + if (!Ctx->AttConnected || Ctx->AttChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || Length == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->AttChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_OUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = Length; + brb->Timeout = 0; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Read raw ATT PDU bytes from the ATT channel (blocking up to TimeoutMs). +// +NTSTATUS +LpAttReceive( + _In_ PDEVICE_CONTEXT Ctx, + _Out_ PVOID Buffer, + _In_ ULONG BufferLen, + _Out_ PULONG BytesRead, + _In_ ULONG TimeoutMs +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + *BytesRead = 0; + + if (!Ctx->AttConnected || Ctx->AttChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || BufferLen == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->AttChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_IN | ACL_SHORT_TRANSFER_OK | ACL_TRANSFER_TIMEOUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = BufferLen; + brb->Timeout = (LONGLONG)(TimeoutMs ? TimeoutMs : LP_RECV_TIMEOUT_MS); + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + if (NT_SUCCESS(status)) { + *BytesRead = brb->BufferSize; + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + // // Close the channel (best-effort). // @@ -294,3 +443,215 @@ LpIndicationCallback( break; } } + +// +// Register an L2CAP server on PSM 0x001F (ATT). The AirPods connect INBOUND here +// for hearing-aid configuration; without a registered server bthport answers their +// Connection Request with "PSM not supported" and the config channel never opens. +// STEP 1: register + log the connect indication (proves the path). Accepting the +// channel (BRB_L2CA_OPEN_CHANNEL_RESPONSE) is Step 2. Non-fatal to the AAP channel. +// +NTSTATUS +LpRegisterAttServer( + _In_ PDEVICE_CONTEXT Ctx +) +{ + NTSTATUS status; + struct _BRB_L2CA_REGISTER_SERVER* brb; + + if (!Ctx->HasBthInterface) { + return STATUS_DEVICE_NOT_READY; + } + if (Ctx->AttServerRegistered) { + return STATUS_SUCCESS; + } + + brb = (struct _BRB_L2CA_REGISTER_SERVER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_REGISTER_SERVER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; // the connected AirPods + brb->PSM = PSM_ATT; + brb->IndicationFlags = 0; + brb->IndicationCallback = LpAttServerIndication; + brb->IndicationCallbackContext = Ctx; + brb->ReferenceObject = Ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->AttRegisterStatus = status; // surfaced via IOCTL_LP_GET_STATUS + if (NT_SUCCESS(status)) { + Ctx->AttServerHandle = brb->ServerHandle; + Ctx->AttServerRegistered = TRUE; + KdPrint(("LibrePodsAAP: ATT server registered on PSM 0x%04X\n", PSM_ATT)); + } else { + KdPrint(("LibrePodsAAP: ATT server register FAILED 0x%08X\n", status)); + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Unregister the ATT server (on device removal). Best-effort. +// +VOID +LpUnregisterAttServer( + _In_ PDEVICE_CONTEXT Ctx +) +{ + struct _BRB_L2CA_UNREGISTER_SERVER* brb; + + if (!Ctx->AttServerRegistered || !Ctx->HasBthInterface) { + return; + } + + brb = (struct _BRB_L2CA_UNREGISTER_SERVER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_UNREGISTER_SERVER, LP_POOL_TAG); + if (brb != NULL) { + brb->BtAddress = 0; + brb->ServerHandle = Ctx->AttServerHandle; + brb->Psm = PSM_ATT; + (VOID)LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + } + Ctx->AttServerRegistered = FALSE; + KdPrint(("LibrePodsAAP: ATT server unregistered\n")); +} + +// +// Server indication: bthport calls this when the AirPods connect to our PSM 0x001F +// server (and, once accepted, on the channel's disconnect). The connect can arrive +// at DISPATCH_LEVEL, so we stash the params and defer the accept to a work item. +// +VOID +LpAttServerIndication( + _In_opt_ PVOID Context, + _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters +) +{ + PDEVICE_CONTEXT ctx = (PDEVICE_CONTEXT)Context; + + if (ctx == NULL) { + return; + } + + switch (Indication) { + case IndicationRemoteConnect: + KdPrint(("LibrePodsAAP: *** ATT connect indication from 0x%012I64X on PSM 0x001F " + "-- accepting ***\n", Parameters->BtAddress)); + WdfSpinLockAcquire(ctx->Lock); + ctx->PendingAttConn = Parameters->ConnectionHandle; + ctx->PendingAttAddr = Parameters->BtAddress; + ctx->AttIndicationCount++; + WdfSpinLockRelease(ctx->Lock); + WdfWorkItemEnqueue(ctx->AttAcceptWorkItem); + break; + case IndicationRemoteDisconnect: + WdfSpinLockAcquire(ctx->Lock); + ctx->AttConnected = FALSE; + ctx->AttChannelHandle = NULL; + WdfSpinLockRelease(ctx->Lock); + KdPrint(("LibrePodsAAP: ATT channel disconnected by remote\n")); + break; + default: + break; + } +} + +// +// Deferred accept (PASSIVE_LEVEL): respond SUCCESS to the AirPods' inbound ATT +// connect, opening the channel we bridge the hearing-aid config over. +// +VOID +LpAttAcceptWorkItem( + _In_ WDFWORKITEM WorkItem +) +{ + PDEVICE_CONTEXT ctx; + NTSTATUS status; + struct _BRB_L2CA_OPEN_CHANNEL* brb; + L2CAP_CHANNEL_HANDLE conn; + BTH_ADDR addr; + + ctx = DeviceGetContext((WDFDEVICE)WdfWorkItemGetParentObject(WorkItem)); + + WdfSpinLockAcquire(ctx->Lock); + conn = ctx->PendingAttConn; + addr = ctx->PendingAttAddr; + WdfSpinLockRelease(ctx->Lock); + + if (!ctx->HasBthInterface || conn == NULL) { + return; + } + + brb = (struct _BRB_L2CA_OPEN_CHANNEL*) + ctx->BthInterface.BthAllocateBrb(BRB_L2CA_OPEN_CHANNEL_RESPONSE, LP_POOL_TAG); + if (brb == NULL) { + return; + } + + brb->ChannelHandle = conn; // from the connect indication + brb->Response = CONNECT_RSP_RESULT_SUCCESS; // accept + brb->ChannelFlags = CF_ROLE_EITHER; + brb->BtAddress = addr; + brb->ConfigOut.Flags = 0; + brb->ConfigIn.Flags = 0; + brb->IncomingQueueDepth = 10; + brb->CallbackFlags = CALLBACK_DISCONNECT; + brb->Callback = LpAttServerIndication; // reused for the channel's disconnect + brb->CallbackContext = ctx; + brb->ReferenceObject = ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(ctx, (PBRB)brb); + WdfSpinLockAcquire(ctx->Lock); + ctx->AttAcceptStatus = status; // surfaced via IOCTL_LP_GET_STATUS + if (NT_SUCCESS(status)) { + ctx->AttChannelHandle = brb->ChannelHandle; + ctx->AttConnected = TRUE; + } + WdfSpinLockRelease(ctx->Lock); + if (NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: *** ATT channel ACCEPTED (handle=%p) ***\n", brb->ChannelHandle)); + } else { + KdPrint(("LibrePodsAAP: ATT accept FAILED 0x%08X\n", status)); + } + + ctx->BthInterface.BthFreeBrb((PBRB)brb); +} + +// +// Close the accepted ATT channel (best-effort, on device removal). +// +VOID +LpCloseAttChannel( + _In_ PDEVICE_CONTEXT Ctx +) +{ + struct _BRB_L2CA_CLOSE_CHANNEL* brb; + L2CAP_CHANNEL_HANDLE handle; + BTH_ADDR addr; + + WdfSpinLockAcquire(Ctx->Lock); + handle = Ctx->AttChannelHandle; + addr = Ctx->PendingAttAddr; + Ctx->AttConnected = FALSE; + Ctx->AttChannelHandle = NULL; + WdfSpinLockRelease(Ctx->Lock); + + if (handle == NULL || !Ctx->HasBthInterface) { + return; + } + + brb = (struct _BRB_L2CA_CLOSE_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_CLOSE_CHANNEL, LP_POOL_TAG); + if (brb != NULL) { + brb->BtAddress = addr; + brb->ChannelHandle = handle; + (VOID)LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + } + KdPrint(("LibrePodsAAP: ATT channel closed\n")); +} diff --git a/crossplatform/windows/drivers/aap/LibrePodsAAP.h b/crossplatform/windows/drivers/aap/LibrePodsAAP.h index f87ce556b..06e114708 100755 --- a/crossplatform/windows/drivers/aap/LibrePodsAAP.h +++ b/crossplatform/windows/drivers/aap/LibrePodsAAP.h @@ -25,6 +25,8 @@ #define LP_POOL_TAG 'PbiL' // "LibP" #define LP_RECV_TIMEOUT_MS 5000 +// PSM_ATT (0x001F) — the AirPods open THIS to us (inbound) for hearing-aid config. +// Already defined by the WDK's bthdef.h, so we just use that. // // User-mode device interface: the LibrePods app enumerates this GUID to find @@ -40,6 +42,9 @@ DEFINE_GUID(GUID_DEVINTERFACE_LIBREPODSAAP, #define IOCTL_LP_SEND CTL_CODE(FILE_DEVICE_LIBREPODS, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_LP_RECEIVE CTL_CODE(FILE_DEVICE_LIBREPODS, 0x803, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_LP_GET_STATUS CTL_CODE(FILE_DEVICE_LIBREPODS, 0x804, METHOD_BUFFERED, FILE_ANY_ACCESS) +// ATT (PSM 0x001F) channel I/O — raw ATT PDUs to/from the AirPods' hearing-aid GATT. +#define IOCTL_LP_ATT_SEND CTL_CODE(FILE_DEVICE_LIBREPODS, 0x805, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_ATT_RECEIVE CTL_CODE(FILE_DEVICE_LIBREPODS, 0x806, METHOD_BUFFERED, FILE_ANY_ACCESS) typedef enum _LP_STATE { LpDisconnected = 0, @@ -69,6 +74,13 @@ typedef struct _LP_RECEIVE_INPUT { typedef struct _LP_STATUS_OUTPUT { ULONG State; // LP_STATE ULONGLONG ConnectedAddress; + // ATT (PSM 0x001F) server diagnostics — so user mode can see the hearing-aid + // channel progress without a kernel debugger (DebugView is unreliable here). + ULONG AttServerRegistered; // 0/1 + ULONG AttIndicationCount; // # of inbound ATT connect indications seen + LONG AttAcceptStatus; // NTSTATUS of the last accept (0x00000103 = not tried) + ULONG AttChannelOpen; // 0/1 (the accepted ATT channel is up) + LONG AttRegisterStatus; // NTSTATUS of the server register (0x00000103 = not tried) } LP_STATUS_OUTPUT, *PLP_STATUS_OUTPUT; #include @@ -97,6 +109,25 @@ typedef struct _DEVICE_CONTEXT { BTH_ADDR RemoteAddress; USHORT Psm; L2CAP_CHANNEL_HANDLE ChannelHandle; + + // ATT (PSM 0x001F) server. The AirPods don't wait for us to connect — right + // after a hearing-aid enable they open an L2CAP channel INBOUND to this PSM, + // which bthport refuses ("PSM not supported") unless we register a server. + L2CAP_SERVER_HANDLE AttServerHandle; + BOOLEAN AttServerRegistered; + + // The connect indication can fire at DISPATCH_LEVEL, where we cannot make the + // blocking BRB submit that accepts the channel. So we stash the connect params + // and defer the accept (BRB_L2CA_OPEN_CHANNEL_RESPONSE) to this PASSIVE-level + // work item. + WDFWORKITEM AttAcceptWorkItem; + L2CAP_CHANNEL_HANDLE PendingAttConn; // connection handle from the indication + BTH_ADDR PendingAttAddr; + L2CAP_CHANNEL_HANDLE AttChannelHandle; // the accepted ATT channel + BOOLEAN AttConnected; + ULONG AttIndicationCount; // # inbound ATT connect indications + LONG AttAcceptStatus; // NTSTATUS of the last accept attempt + LONG AttRegisterStatus; // NTSTATUS of the server register } DEVICE_CONTEXT, *PDEVICE_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, DeviceGetContext) @@ -130,4 +161,22 @@ _Function_class_(PFNBTHPORT_INDICATION_CALLBACK) VOID LpIndicationCallback(_In_opt_ PVOID Context, _In_ INDICATION_CODE Indication, _In_ PINDICATION_PARAMETERS Parameters); +// ATT (PSM 0x001F) — we OPEN it as a CLIENT to the AirPods (like Android's +// ATTManager), which sidesteps the bthport rule that forbids registering a SERVER +// on the reserved ATT PSM. LpConnectAtt opens it; LpCloseAttChannel tears it down. +// (The server-register path below is kept but unused — it returned 0xC000000D.) +NTSTATUS LpConnectAtt(_In_ PDEVICE_CONTEXT Ctx); +NTSTATUS LpAttSend(_In_ PDEVICE_CONTEXT Ctx, _In_reads_bytes_(Length) PVOID Buffer, _In_ ULONG Length); +NTSTATUS LpAttReceive(_In_ PDEVICE_CONTEXT Ctx, _Out_writes_bytes_(BufferLen) PVOID Buffer, + _In_ ULONG BufferLen, _Out_ PULONG BytesRead, _In_ ULONG TimeoutMs); +NTSTATUS LpRegisterAttServer(_In_ PDEVICE_CONTEXT Ctx); +VOID LpUnregisterAttServer(_In_ PDEVICE_CONTEXT Ctx); +VOID LpCloseAttChannel(_In_ PDEVICE_CONTEXT Ctx); + +_Function_class_(PFNBTHPORT_INDICATION_CALLBACK) +VOID LpAttServerIndication(_In_opt_ PVOID Context, _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters); + +EVT_WDF_WORKITEM LpAttAcceptWorkItem; + #endif // _LIBREPODSAAP_H_ diff --git a/crossplatform/windows/ipc/src/lib.rs b/crossplatform/windows/ipc/src/lib.rs index 591202646..0d8961268 100644 --- a/crossplatform/windows/ipc/src/lib.rs +++ b/crossplatform/windows/ipc/src/lib.rs @@ -110,6 +110,16 @@ pub enum Command { /// default because it drains battery). On sends the RTBuddy enable sequence; /// off sends the stop frame and clears `heart_rate`. SetHeartRate { on: bool }, + /// AirPods Pro 3 hearing assistance (accessibility amplification). On enables + /// hearing-assist over AAP (0x2C/0x33) and writes the amplification settings to + /// the ATT/GATT (PSM 0x001F, handle 0x2A); off disables it. `amplification` + /// 0.0..=1.0 overall gain, `balance` -1.0(L)..=1.0(R), plus conversation boost. + SetHearingAid { + on: bool, + amplification: f32, + balance: f32, + conversation_boost: bool, + }, /// Start the AAP session (the user accepted the "connect?" prompt). Connect, /// Release the AAP control session (the "Disconnect" button). Stops diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml b/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml new file mode 100644 index 000000000..030f73441 --- /dev/null +++ b/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs b/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs new file mode 100644 index 000000000..e6f0aab5e --- /dev/null +++ b/crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs @@ -0,0 +1,51 @@ +using System; +using LibrePods.WinUI.Ipc; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace LibrePods.WinUI.Controls; + +/// Hearing assistance (AirPods Pro 3, experimental accessibility amplification). +/// A toggle plus amplification/balance sliders and a conversation-boost switch. The +/// daemon enables hearing-assist over AAP and writes the settings to the ATT/GATT. +/// Slider changes are debounced (each apply is a ~1.3 s enable + ATT round-trip). +public sealed partial class HearingAidCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private readonly DispatcherTimer _debounce = new() { Interval = TimeSpan.FromMilliseconds(500) }; + + public HearingAidCard() + { + InitializeComponent(); + _debounce.Tick += (_, _) => { _debounce.Stop(); Apply(); }; + } + + private void Enable_Toggled(object sender, RoutedEventArgs e) + { + bool on = EnableSwitch.IsOn; + AmpSlider.IsEnabled = on; + BalanceSlider.IsEnabled = on; + ConvBoostSwitch.IsEnabled = on; + _debounce.Stop(); + Apply(); // enabling/disabling applies immediately + } + + private void Settings_Changed(object sender, RangeBaseValueChangedEventArgs e) + { + if (EnableSwitch.IsOn) { _debounce.Stop(); _debounce.Start(); } + } + + private void ConvBoost_Toggled(object sender, RoutedEventArgs e) + { + if (EnableSwitch.IsOn) { _debounce.Stop(); _debounce.Start(); } + } + + private void Apply() + { + var amp = (float)(AmpSlider.Value / 100.0); // 0..1 + var bal = (float)(BalanceSlider.Value / 100.0); // -1..1 + Client?.SetHearingAid(EnableSwitch.IsOn, amp, bal, ConvBoostSwitch.IsOn); + } +} diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs b/crossplatform/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs index 6f25024e4..2b421ea49 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs +++ b/crossplatform/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs @@ -64,6 +64,8 @@ public void Send(object command) public void SetMicMode(bool auto, bool manual) => Send(new SetMicModeCmd { Auto = auto, Manual = manual }); public void SetFeature(byte feature, bool on) => Send(new SetFeatureCmd { Feature = feature, On = on }); public void SetControl(byte id, byte value) => Send(new SetControlCmd { Id = id, Value = value }); + public void SetHearingAid(bool on, float amplification, float balance, bool conversationBoost) => + Send(new SetHearingAidCmd { On = on, Amplification = amplification, Balance = balance, ConversationBoost = conversationBoost }); public void StepVolume(int delta) => Send(new StepVolumeCmd { Delta = delta }); public void SetVolume(byte percent) => Send(new SetVolumeCmd { Percent = percent }); public void ToggleMute() => Send(new ToggleMuteCmd()); diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Ipc/Messages.cs b/crossplatform/windows/winui/LibrePods.WinUI/Ipc/Messages.cs index 20eb78565..7ea8c91a4 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Ipc/Messages.cs +++ b/crossplatform/windows/winui/LibrePods.WinUI/Ipc/Messages.cs @@ -128,6 +128,15 @@ public sealed class SetHeartRateCmd [JsonPropertyName("on")] public bool On { get; init; } } +public sealed class SetHearingAidCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_hearing_aid"; + [JsonPropertyName("on")] public bool On { get; init; } + [JsonPropertyName("amplification")] public float Amplification { get; init; } + [JsonPropertyName("balance")] public float Balance { get; init; } + [JsonPropertyName("conversation_boost")] public bool ConversationBoost { get; init; } +} + public sealed class ConnectCmd { [JsonPropertyName("cmd")] public string Cmd => "connect"; diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml b/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml index 261598c39..7dd34645d 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml +++ b/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml @@ -68,6 +68,7 @@ Settings ▸ Experimental; visibility is set from AppSettings in the DevicePage code-behind. --> + diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs b/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs index 7006c2ff9..ec0a002f7 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs +++ b/crossplatform/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs @@ -26,6 +26,7 @@ public DaemonClient? Client FeaturesCard.Client = value; MicCard.Client = value; HeartRateCard.Client = value; + HearingAidCard.Client = value; } } diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw b/crossplatform/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw index 1793c7d3d..c7307d795 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw +++ b/crossplatform/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw @@ -117,6 +117,15 @@ Auto-enable on recording Enable hi-res mic now + + Hearing assistance + Amplify the world around you through your AirPods (Pro 3). + Experimental + Amplification without a fitted audiogram may cause feedback — keep it at a comfortable level. + Hearing aid + Amplification + Balance (left / right) + Conversation boost Heart Rate AirPods Pro 3 only — experimental. Drains battery. diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw b/crossplatform/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw index 98a9dd0dd..57d032c8d 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw +++ b/crossplatform/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw @@ -108,6 +108,15 @@ Activar automáticamente al grabar Activar micrófono HD ahora + + Asistencia auditiva + Amplifica el sonido a tu alrededor a través de los AirPods (Pro 3). + Experimental + La amplificación sin un audiograma adecuado puede causar acoples — mantenla a un nivel cómodo. + Audífono + Amplificación + Balance (izquierda / derecha) + Refuerzo de conversación Frecuencia cardíaca Solo AirPods Pro 3 — experimental. Consume batería. diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw b/crossplatform/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw index 3c843cbfb..b0808b501 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw +++ b/crossplatform/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw @@ -108,6 +108,15 @@ Activer automatiquement à l'enregistrement Activer le micro HD maintenant + + Assistance auditive + Amplifiez le son autour de vous via vos AirPods (Pro 3). + Expérimental + Une amplification sans audiogramme adapté peut provoquer du larsen — gardez un niveau confortable. + Aide auditive + Amplification + Balance (gauche / droite) + Renfort de conversation Fréquence cardiaque AirPods Pro 3 uniquement — expérimental. Consomme la batterie. diff --git a/crossplatform/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw b/crossplatform/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw index d80a9652c..715b5194f 100644 --- a/crossplatform/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw +++ b/crossplatform/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw @@ -108,6 +108,15 @@ Ativar automaticamente ao gravar Ativar microfone agora + + Assistência auditiva + Amplifica o som à tua volta através dos AirPods (Pro 3). + Experimental + Amplificação sem um audiograma adequado pode causar feedback — mantém num nível confortável. + Aparelho auditivo + Amplificação + Balanço (esquerda / direita) + Reforço de conversa Frequência Cardíaca Apenas AirPods Pro 3 — experimental. Consome bateria.