From a58c77986d9a1cbaf5dd8a28096939f9e7b8882d Mon Sep 17 00:00:00 2001 From: arctumn Date: Mon, 10 Aug 2026 19:25:45 +0100 Subject: [PATCH 1/3] hearing-aid: open the AirPods ATT (PSM 0x001F) channel + read/write its GATT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hearing-aid audiogram lives on the AirPods' ATT/GATT (PSM 0x001F). The buds open that channel INBOUND to the host, which bthport refuses ('PSM not supported') — and a profile driver CANNOT register an L2CAP server on the reserved ATT PSM (BRB_L2CA_ REGISTER_SERVER returns STATUS_INVALID_PARAMETER 0xC000000D). The fix, matching Android's ATTManager: OPEN the channel as a CLIENT (BRB_L2CA_OPEN_CHANNEL to PSM 0x001F) — the same outbound path we use for AAP 0x1001. Connecting as a client to a reserved PSM is allowed; the buds accept it and we then speak ATT. Driver: LpConnectAtt (client open, called from LpConnect once the AAP link is up), LpAttSend/LpAttReceive (BRB_L2CA_ACL_TRANSFER on the ATT channel) exposed via IOCTL_LP_ATT_SEND/RECEIVE, LpCloseAttChannel on teardown, and ATT diagnostics surfaced through IOCTL_LP_GET_STATUS (register/accept/channel-open status) since DebugView never showed our KdPrint. The unused server-register/accept path is kept for reference. Daemon: driver.rs att_send/att_recv/att_diag; run_receiver logs the ATT state to daemon.log. Proven end-to-end: enabling hearing-assist (0x2C/0x33) then ATT-reading handle 0x2A returns the 104-byte hearing-aid settings buffer (ATT Read Response 0x0B), and CCCD notif-enable (0x2B) returns a Write Response (0x13). GATT discovery shows two custom Apple services — no standard Heart Rate service, consistent with HR being AAP-only. NB the current att_probe auto-enables hearing-assist + probes/discovers on every connect — that is test scaffolding; the shipped feature needs a real SetHearingAid IPC command and the audiogram write. Head-tracking (AAP 0x17, per upstream issue #713) is a separate promising lead, untested here. --- crossplatform/windows/daemon/src/driver.rs | 32 +- crossplatform/windows/daemon/src/main.rs | 77 ++++ crossplatform/windows/drivers/aap/Device.c | 10 + crossplatform/windows/drivers/aap/Driver.c | 17 + crossplatform/windows/drivers/aap/Ioctl.c | 48 ++- crossplatform/windows/drivers/aap/L2cap.c | 361 ++++++++++++++++++ .../windows/drivers/aap/LibrePodsAAP.h | 49 +++ 7 files changed, 589 insertions(+), 5 deletions(-) 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/main.rs b/crossplatform/windows/daemon/src/main.rs index 8464081fc..c37328153 100644 --- a/crossplatform/windows/daemon/src/main.rs +++ b/crossplatform/windows/daemon/src/main.rs @@ -790,6 +790,62 @@ fn apply_command(ctx: &Ctx, cmd: Command) { /// The AAP session: keep the link up, decode the mic, track battery/ANC/ear /// detection, and broadcast state + overlay events. (Ported from the tray.) +/// One-shot probe of the ATT (PSM 0x001F) hearing-aid channel once it opens: +/// enable notifications on the CCCD (handle 0x2B) and read the settings +/// characteristic (handle 0x2A). Logs the raw responses so we can confirm the ATT +/// read/write path works end-to-end before wiring the full audiogram flow. +fn att_probe(drv: &driver::Driver) { + let hex = |d: &[u8]| d.iter().map(|b| format!("{b:02x}")).collect::>().join(" "); + // Wake the buds' hearing-aid ATT server first: enable hearing-assist over the + // AAP channel (0x2C [01 01] + 0x33 [01]). The ATT (handle 0x2A) server appears + // dormant until this is on, so a bare read/write times out. + let _ = drv.send(&[0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x00]); + std::thread::sleep(Duration::from_millis(400)); + let _ = drv.send(&[0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x01, 0x00, 0x00, 0x00]); + std::thread::sleep(Duration::from_millis(900)); + // Enable notifications: ATT Write Request (0x12) to CCCD handle 0x2B = 01 00. + let _ = drv.att_send(&[0x12, 0x2B, 0x00, 0x01, 0x00]); + let mut buf = [0u8; 512]; + match drv.att_recv(3000, &mut buf) { + Ok(n) if n > 0 => log(&format!("ATT notif-enable resp [{n}]: {}", hex(&buf[..n]))), + Ok(_) => log("ATT notif-enable: no response"), + Err(e) => log(&format!("ATT notif-enable recv err: {e}")), + } + // Read the hearing-aid settings characteristic: ATT Read Request (0x0A) handle 0x2A. + let _ = drv.att_send(&[0x0A, 0x2A, 0x00]); + match drv.att_recv(3000, &mut buf) { + Ok(n) if n > 0 => log(&format!("ATT read 0x2A resp [{n}]: {}", hex(&buf[..n]))), + Ok(_) => log("ATT read 0x2A: no response"), + Err(e) => log(&format!("ATT read 0x2A recv err: {e}")), + } + + // GATT DISCOVERY: enumerate every handle->UUID (ATT Find Information, 0x04) so we + // can spot a Heart Rate service (0x180D) / Heart Rate Measurement char (0x2A37) + // or anything else the buds expose over this GATT that the AAP path never did. + // Full handle->UUID map: iterate Find Information from 0x0001 upward. + let mut start: u16 = 0x0001; + for _ in 0..24 { + let _ = drv.att_send(&[0x04, (start & 0xff) as u8, (start >> 8) as u8, 0xff, 0xff]); + let n = match drv.att_recv(1500, &mut buf) { Ok(n) => n, Err(_) => { break; } }; + if n < 4 || buf[0] != 0x05 { log(&format!("ATT findinfo@0x{start:04x} end [{n}]: {}", hex(&buf[..n.max(1).min(n)]))); break; } + log(&format!("ATT map@0x{start:04x} [{n}]: {}", hex(&buf[..n]))); + let step = if buf[1] == 1 { 4usize } else { 18usize }; + let mut last = start; + let mut i = 2usize; + while i + step <= n { last = u16::from_le_bytes([buf[i], buf[i + 1]]); i += step; } + if last >= 0xffff || last < start { break; } + start = last + 1; + } + // Peek at Service 1 (handles 0x0003..0x0011): read each value. + for h in 0x0003u16..=0x0011 { + let _ = drv.att_send(&[0x0A, (h & 0xff) as u8, (h >> 8) as u8]); + if let Ok(n) = drv.att_recv(1200, &mut buf) { + if n > 0 { log(&format!("ATT read 0x{h:04x} [{n}]: {}", hex(&buf[..n]))); } + } + } + log("ATT discovery done"); +} + fn run_receiver(ctx: Ctx) { let mac = ctx.mac; log("run_receiver: entered"); @@ -893,7 +949,28 @@ 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; + 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 + )); + // First time the ATT channel comes up: probe it end-to-end. + if d.4 == 1 && was_open == 0 { + att_probe(&driver); + } + } + } + } // 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_ From 9f6321ae3122c5446225f1a25e60119c0560b12e Mon Sep 17 00:00:00 2001 From: arctumn Date: Mon, 10 Aug 2026 19:40:38 +0100 Subject: [PATCH 2/3] hearing-aid: SetHearingAid command + daemon apply + WinUI card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the proven ATT read/write into a real feature. New IPC command Command::SetHearingAid { on, amplification, balance, conversation_boost }; daemon hearing.rs applies it — enables hearing-assist over AAP (0x2C/0x33) then read- modify-writes the ATT/GATT settings characteristic (handle 0x2A): patches per-ear amplification (from amplification + balance), tone and conversation-boost as LE f32 at the Android offsets, leaving the audiogram EQ bands untouched. Runs on its own thread (the enable settle + ATT round-trips take >1 s). The connect-time att_probe scaffolding is removed; the ATT status logging stays. WinUI: Controls/HearingAidCard (on/off toggle + amplification/balance sliders + conversation-boost, debounced), wired into DevicePage and DaemonClient.SetHearingAid; localized in all four languages. Slider values map 0..100 -> 0..1 amplification and -100..100 -> -1..1 balance. Not yet hardware-validated (the ATT WRITE — only the read was proven; the AirPods kept dropping late in the session); the enable + read path is confirmed working. --- crossplatform/windows/daemon/src/hearing.rs | 107 ++++++++++++++++++ crossplatform/windows/daemon/src/main.rs | 76 +++---------- crossplatform/windows/ipc/src/lib.rs | 10 ++ .../Controls/HearingAidCard.xaml | 47 ++++++++ .../Controls/HearingAidCard.xaml.cs | 51 +++++++++ .../winui/LibrePods.WinUI/Ipc/DaemonClient.cs | 2 + .../winui/LibrePods.WinUI/Ipc/Messages.cs | 9 ++ .../LibrePods.WinUI/Pages/DevicePage.xaml | 1 + .../LibrePods.WinUI/Pages/DevicePage.xaml.cs | 1 + .../Strings/en-US/Resources.resw | 9 ++ .../Strings/es-ES/Resources.resw | 9 ++ .../Strings/fr-FR/Resources.resw | 9 ++ .../Strings/pt-PT/Resources.resw | 9 ++ 13 files changed, 280 insertions(+), 60 deletions(-) create mode 100644 crossplatform/windows/daemon/src/hearing.rs create mode 100644 crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml create mode 100644 crossplatform/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs diff --git a/crossplatform/windows/daemon/src/hearing.rs b/crossplatform/windows/daemon/src/hearing.rs new file mode 100644 index 000000000..9ebab313b --- /dev/null +++ b/crossplatform/windows/daemon/src/hearing.rs @@ -0,0 +1,107 @@ +//! 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::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). + let _ = drv.send(&HA_ON_2C); + thread::sleep(Duration::from_millis(400)); + 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; + } + 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 c37328153..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. @@ -790,62 +805,6 @@ fn apply_command(ctx: &Ctx, cmd: Command) { /// The AAP session: keep the link up, decode the mic, track battery/ANC/ear /// detection, and broadcast state + overlay events. (Ported from the tray.) -/// One-shot probe of the ATT (PSM 0x001F) hearing-aid channel once it opens: -/// enable notifications on the CCCD (handle 0x2B) and read the settings -/// characteristic (handle 0x2A). Logs the raw responses so we can confirm the ATT -/// read/write path works end-to-end before wiring the full audiogram flow. -fn att_probe(drv: &driver::Driver) { - let hex = |d: &[u8]| d.iter().map(|b| format!("{b:02x}")).collect::>().join(" "); - // Wake the buds' hearing-aid ATT server first: enable hearing-assist over the - // AAP channel (0x2C [01 01] + 0x33 [01]). The ATT (handle 0x2A) server appears - // dormant until this is on, so a bare read/write times out. - let _ = drv.send(&[0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x00]); - std::thread::sleep(Duration::from_millis(400)); - let _ = drv.send(&[0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x01, 0x00, 0x00, 0x00]); - std::thread::sleep(Duration::from_millis(900)); - // Enable notifications: ATT Write Request (0x12) to CCCD handle 0x2B = 01 00. - let _ = drv.att_send(&[0x12, 0x2B, 0x00, 0x01, 0x00]); - let mut buf = [0u8; 512]; - match drv.att_recv(3000, &mut buf) { - Ok(n) if n > 0 => log(&format!("ATT notif-enable resp [{n}]: {}", hex(&buf[..n]))), - Ok(_) => log("ATT notif-enable: no response"), - Err(e) => log(&format!("ATT notif-enable recv err: {e}")), - } - // Read the hearing-aid settings characteristic: ATT Read Request (0x0A) handle 0x2A. - let _ = drv.att_send(&[0x0A, 0x2A, 0x00]); - match drv.att_recv(3000, &mut buf) { - Ok(n) if n > 0 => log(&format!("ATT read 0x2A resp [{n}]: {}", hex(&buf[..n]))), - Ok(_) => log("ATT read 0x2A: no response"), - Err(e) => log(&format!("ATT read 0x2A recv err: {e}")), - } - - // GATT DISCOVERY: enumerate every handle->UUID (ATT Find Information, 0x04) so we - // can spot a Heart Rate service (0x180D) / Heart Rate Measurement char (0x2A37) - // or anything else the buds expose over this GATT that the AAP path never did. - // Full handle->UUID map: iterate Find Information from 0x0001 upward. - let mut start: u16 = 0x0001; - for _ in 0..24 { - let _ = drv.att_send(&[0x04, (start & 0xff) as u8, (start >> 8) as u8, 0xff, 0xff]); - let n = match drv.att_recv(1500, &mut buf) { Ok(n) => n, Err(_) => { break; } }; - if n < 4 || buf[0] != 0x05 { log(&format!("ATT findinfo@0x{start:04x} end [{n}]: {}", hex(&buf[..n.max(1).min(n)]))); break; } - log(&format!("ATT map@0x{start:04x} [{n}]: {}", hex(&buf[..n]))); - let step = if buf[1] == 1 { 4usize } else { 18usize }; - let mut last = start; - let mut i = 2usize; - while i + step <= n { last = u16::from_le_bytes([buf[i], buf[i + 1]]); i += step; } - if last >= 0xffff || last < start { break; } - start = last + 1; - } - // Peek at Service 1 (handles 0x0003..0x0011): read each value. - for h in 0x0003u16..=0x0011 { - let _ = drv.att_send(&[0x0A, (h & 0xff) as u8, (h >> 8) as u8]); - if let Ok(n) = drv.att_recv(1200, &mut buf) { - if n > 0 { log(&format!("ATT read 0x{h:04x} [{n}]: {}", hex(&buf[..n]))); } - } - } - log("ATT discovery done"); -} - fn run_receiver(ctx: Ctx) { let mac = ctx.mac; log("run_receiver: entered"); @@ -960,14 +919,11 @@ fn run_receiver(ctx: Ctx) { 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 )); - // First time the ATT channel comes up: probe it end-to-end. - if d.4 == 1 && was_open == 0 { - att_probe(&driver); - } } } } 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. From 3534a1422ea37058940d87d6ae5f1507ccdff673 Mon Sep 17 00:00:00 2001 From: arctumn Date: Mon, 10 Aug 2026 22:59:43 +0100 Subject: [PATCH 3/3] hearing-aid: switch to Transparency + write a flat audiogram so amplification is audible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ATT write succeeded but nothing was audible: the settings were written with a zero audiogram (nothing for the amplification to scale) and the buds weren't passing ambient. Now on enable we (1) switch noise control to Transparency (mode 3) so ambient sound comes through, and (2) synthesize a flat broadband audiogram across all 8 EQ bands per ear from the amplification slider. Confirmed audible on hardware — ambient noise is amplified and the tonal balance is clearly EQ-controllable (proving full audiogram control). BAND_GAIN (30) and the flat curve are first guesses; a natural hearing-aid curve (high-frequency emphasis) is the tuning left for next session. --- crossplatform/windows/daemon/src/hearing.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crossplatform/windows/daemon/src/hearing.rs b/crossplatform/windows/daemon/src/hearing.rs index 9ebab313b..a1f74bb76 100644 --- a/crossplatform/windows/daemon/src/hearing.rs +++ b/crossplatform/windows/daemon/src/hearing.rs @@ -5,6 +5,7 @@ //! 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}; @@ -58,9 +59,13 @@ pub fn apply( return Ok("hearing aid OFF".into()); } - // 1) Wake the buds' hearing-aid ATT server (it is dormant until enabled). + // 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(400)); + 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)); @@ -88,6 +93,15 @@ pub fn apply( 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);