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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1279,26 +1279,82 @@ class AACPManager {
audioSource = null
}

/**
* Parses unsolicited `0x001D` device information.
* See project docs: `docs/device-info.md` (nested headers / offset scan on some Android hosts).
*/
fun parseInformationPacket(packet: ByteArray): AirPodsInformation {
val data = packet.sliceArray(6 until packet.size)
val candidates = mutableListOf<List<String>>()

candidates.add(parseInformationStringsAtOffset(packet, 6))

val scanLimit = minOf(packet.size - 10, 96)
for (offset in 0 until scanLimit) {
if (packet.getOrNull(offset) == 0x04.toByte() &&
packet.getOrNull(offset + 1) == 0x00 &&
packet.getOrNull(offset + 2) == 0x04.toByte() &&
packet.getOrNull(offset + 3) == 0x00 &&
packet.getOrNull(offset + 4) == Opcodes.INFORMATION &&
packet.getOrNull(offset + 5) == 0x00
) {
candidates.add(parseInformationStringsAtOffset(packet, offset + 6))
}
}

for (offset in 0 until minOf(packet.size - 16, 64)) {
val strings = parseInformationStringsAtOffset(packet, offset)
if (strings.size >= 4) {
candidates.add(strings)
}
}

val strings = candidates.maxByOrNull { scoreDeviceInformationFields(it) }
?: emptyList()

return airPodsInformationFromFieldStrings(strings)
}

var index = 0
while (index < data.size && data[index] != 0x00.toByte()) index++
private fun parseInformationStringsAtOffset(packet: ByteArray, payloadStart: Int): List<String> {
if (payloadStart >= packet.size) return emptyList()

var index = payloadStart
while (index < packet.size && packet[index] != 0x00.toByte()) index++

val strings = mutableListOf<String>()
while (index < data.size) {
// skip 0x00 bytes
while (index < data.size && data[index] == 0x00.toByte()) index++
if (index >= data.size) break
while (index < packet.size) {
while (index < packet.size && packet[index] == 0x00.toByte()) index++
if (index >= packet.size) break
val start = index
// find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++
val str = data.sliceArray(start until index).decodeToString()
strings.add(str)
while (index < packet.size && packet[index] != 0x00.toByte()) index++
val slice = packet.sliceArray(start until index)
if (!slice.all { it in 0x20..0x7E }) {
break
}
strings.add(slice.decodeToString())
}

strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string
if (strings.isNotEmpty() && strings[0].isEmpty()) {
strings.removeAt(0)
}
return strings
}

private fun scoreDeviceInformationFields(strings: List<String>): Int {
if (strings.isEmpty()) return 0
var score = strings.size
if (strings.getOrNull(2) == "Apple Inc.") score += 6
val model = strings.getOrNull(1)
if (model != null && model.length == 5 && model[0] == 'A' && model.drop(1).all { it.isDigit() }) {
score += 3
}
val serial = strings.getOrNull(3)
if (serial != null && serial.length in 10..12 && serial.all { it.isLetterOrDigit() }) {
score += 2
}
return score
}

private fun airPodsInformationFromFieldStrings(strings: List<String>): AirPodsInformation {
return AirPodsInformation(
name = strings.getOrNull(0) ?: "",
modelNumber = strings.getOrNull(1) ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2740,12 +2740,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
this@AirPodsService.device = device
BluetoothConnectionManager.aacpSocket?.let {
aacpManager.sendPacket(aacpManager.createHandshakePacket())
aacpManager.sendSetFeatureFlagsPacket()
aacpManager.sendNotificationRequest()
Log.d(TAG, "Requesting proximity keys")
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
CoroutineScope(Dispatchers.IO).launch {
aacpManager.sendPacket(aacpManager.createHandshakePacket())
delay(200)
aacpManager.sendSetFeatureFlagsPacket()
delay(200)
aacpManager.sendNotificationRequest()
delay(200)
Log.d(TAG, "Requesting proximity keys")
aacpManager.sendRequestProximityKeys(
(AACPManager.Companion.ProximityKeyType.IRK.value +
AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()
)
delay(200)
aacpManager.sendPacket(aacpManager.createHandshakePacket())
delay(200)
Expand Down
2 changes: 2 additions & 0 deletions docs/AAP Definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This packet is necessary to establish a connection with the AirPods. Or else, th
00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00
```

After the handshake, third-party hosts should send [host capabilities (`0x004D`)](/docs/host-capabilities.md) before requesting notifications. See that document for timing notes and Android L2CAP pitfalls.

# Setting specific features for AirPods Pro 2

> *may work for airpods 4 anc also, not tested*
Expand Down
20 changes: 20 additions & 0 deletions docs/device-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,23 @@ The data is in this order:
- Version (?) (I have `8454371`)
- A few more bytes, I don't know what they are

## Push-only

Hosts cannot request this opcode. Opcode `0x004F` does not provide a reliable way to read serials or model numbers from the accessory.

After L2CAP connect, send the [handshake](/docs/AAP%20Definitions.md#handshake) and [host capabilities `0x004D`](/docs/host-capabilities.md) so the accessory is likely to include `0x001D` in the startup burst.

## Parsing on third-party hosts (especially Android)

On some non-Apple stacks the `0x001D` SDU is not always aligned at a fixed byte offset after the six-byte `04 00 04 00` + opcode header:

- The payload may include **length prefixes** or an extra nested `04 00 04 00` header before the UTF-8 string block.
- Fields remain **null-terminated strings** in the order listed above once the string run is found.

Implementations should **scan the SDU** for a plausible sequence of printable UTF-8 strings (name, model, `Apple Inc.`, serial-shaped tokens, version strings) instead of assuming parsing always starts at byte index 6.

A single session may deliver **more than one** `0x001D` packet with different lengths. When duplicates disagree, prefer the frame with the **most complete** set of fields.

## Battery report (`0x0004`)

Unsolicited battery packets may arrive before or after `0x001D`. On some Android L2CAP sessions battery reports are sparse or absent even when `0x001D` was received successfully.
55 changes: 55 additions & 0 deletions docs/host-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
opcode: 0x004D
title: Host capabilities (feature flags)
description: Sent by the host to the accessory after the initial L2CAP handshake so the accessory emits the full startup notification burst.
---

## Overview

After the [handshake](/docs/AAP%20Definitions.md#handshake) on PSM `0x1001`, third-party hosts should send opcode **`0x004D`** before (or shortly before) [requesting notifications](/docs/AAP%20Definitions.md#requesting-notifications). The accessory uses this to decide which host-side features are supported and often follows with a burst of unsolicited packets (for example paired-device metadata `0x002B` and [device information](/docs/device-info.md) `0x001D`).

This opcode is **not** a poll for device info; it only advertises host capability. Device information remains push-only on `0x001D`.

## Packet shape

Typical framing uses the usual AACP prefix `04 00 04 00`, little-endian opcode `4D 00`, then a capability bitmask or feature bytes.

### Observed on macOS (PacketLogger)

Documented in [AAP Definitions](/docs/AAP%20Definitions.md#setting-specific-features-for-airpods-pro-2):

```plaintext
04 00 04 00 4d 00 ff 00 00 00 00 00 00 00
```

### Observed in LibrePods (Android)

The Android app sends a longer payload starting with `D7` (see `AACPManager.createSetFeatureFlagsPacket()`).

### Other third-party clients

Some Android clients use a shorter capability byte (for example `FF`) with the same opcode. Behavior can vary by accessory firmware; if the startup burst is thin (short `0x002B` only, no `0x001D`), verify that `0x004D` is sent and that the host is not discarding early inbound data.

## Recommended connection order (third-party hosts)

1. Open L2CAP to PSM `0x1001` on a bonded device.
2. Send handshake `0x0001`.
3. Wait roughly **100–350 ms** (some Android stacks are timing-sensitive).
4. Send **`0x004D`** host capabilities.
5. Wait roughly **100–350 ms**.
6. Send **`0x000F`** notification register.
7. Read and process inbound packets continuously; **`0x001D` may arrive before step 6 completes**.

## Client pitfalls

- **Do not drain or drop the socket receive queue** immediately after connect. The accessory may already have queued `0x001D` or `0x002B` during the handshake burst.
- **Do not rely on `0x004F`** to fetch serials or model data; it does not behave like a device-info read even with Apple Device ID spoofing.
- On Android, L2CAP to AACP may require stack-specific socket construction; see [Google issue 371713238](https://issuetracker.google.com/issues/371713238) and [capod#215](https://github.com/d4rken-org/capod/issues/215).

## Related opcodes

| Opcode | Direction (typical) | Notes |
| ------ | ------------------- | ----- |
| `0x0029` | Host → accessory (?) | Also associated with host capabilities in some captures; less common in open-source clients than `0x004D`. |
| `0x002B` | Accessory → host | Often appears in the startup burst after capabilities. |
| `0x001D` | Accessory → host | [Device information](/docs/device-info.md). |
2 changes: 1 addition & 1 deletion docs/opcodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ AACP (Apple Accessory Communication Protocol) uses various opcodes to define dif
| 0x0030 | Accessory | [BLE keys req](/docs/ble-keys.md) |
| 0x0031 | Host | [BLE keys response](/docs/ble-keys.md) |
| 0x004B | Host | [Conversation awareness](/docs/conversational-awareness.md) |
| 0x004D | Accessory | [Host capabilities](/docs/host-capabilities.md) |
| 0x004D | Accessory | [Host capabilities](/docs/host-capabilities.md) (host → accessory) |
| 0x004F | Both | Information req/res (doesn't work, even with apple's DID) |
| 0x0053 | Both | [EQ data](/docs/eq.md) |