mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat: Add AAP session tests for AirPods Pro 2 USB-C
Add AirPodsPro2UsbcAapSessionTest with real captured bytes from a live device session (model A3048, Pixel 8, 2026-04-02). Covers handshake, device info, battery states, private keys, all Pro 2 USB-C settings (including ADAPTIVE ANC, VolumeSwipe, ConversationalAwareness), ear detection across 6 transitions, and unhandled messages. Also document ChargingState observations across models and EndCallMuteMic subtype variations in code comments.
This commit is contained in:
@@ -75,12 +75,14 @@ data class AapPodState(
|
||||
}
|
||||
|
||||
enum class ChargingState(val wireValue: Int) {
|
||||
// Pro 1 sends 0x00 transiently when a pod is first removed from the case (reverts to NOT_CHARGING within seconds).
|
||||
// Pro 2 USB-C skips this and reports NOT_CHARGING immediately. Likely a firmware timing difference.
|
||||
UNDEFINED(0x00),
|
||||
CHARGING(0x01),
|
||||
NOT_CHARGING(0x02),
|
||||
DISCONNECTED(0x04),
|
||||
// TODO: Observed on AirPods Pro 3 in charging case. Possibly Apple's "Optimized Battery Charging" limit.
|
||||
// Verify behavior with Pro 2 and Pro 1 to confirm whether this is model-specific or firmware-specific.
|
||||
// Observed on Pro 3 and Pro 2 USB-C in charging case. Apple's "Optimized Battery Charging" feature.
|
||||
// Not observed on Pro 1 (firmware 51.9.6) — may require newer firmware or hardware.
|
||||
CHARGING_OPTIMIZED(0x05),
|
||||
;
|
||||
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ class DefaultAapDeviceProfile(
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
}
|
||||
0x00, 0x20 -> {
|
||||
// Alternate/compact response format: byte 2 is a combined mode value
|
||||
// Compact response format: byte 2 is a combined mode value.
|
||||
// 0x00: observed on Pro 1 (fw 51.9.6, 2026-04-02) and Pro 2 USB-C (fw 81.x, 2026-04-02) and Pro 3.
|
||||
// 0x20: observed on Pro 1 (fw 51.9.6, 2026-03-31). Same device can send either subtype across sessions.
|
||||
val combined = payload[2].toInt() and 0xFF
|
||||
val (muteMic, endCall) = when (combined) {
|
||||
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.devices.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.BaseAapSessionTest
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests the full AAP decode pipeline using real captured data from AirPods Pro 2 (USB-C).
|
||||
* Each test uses exact bytes observed on a live device via L2CAP SEQPACKET reads.
|
||||
*
|
||||
* Device: AirPods Pro 2 USB-C (model A3048, product ID 0x2420)
|
||||
* Phone: Pixel 8 (Android 17 Beta)
|
||||
* Captured: 2026-04-02
|
||||
*/
|
||||
class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
override val podModel = PodModel.AIRPODS_PRO2_USBC
|
||||
|
||||
// ── Handshake ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `handshake response - 18 bytes`() {
|
||||
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
|
||||
msg.commandType shouldBe 0x0000
|
||||
msg.raw.size shouldBe 18
|
||||
msg.payload.size shouldBe 12
|
||||
}
|
||||
|
||||
// ── Device Info ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `device info - 232 bytes`() {
|
||||
val msg = aapMessage(
|
||||
"04 00 04 00 1D 00 02 DF 00 04 00",
|
||||
"41 69 72 50 6F 64 73 20 50 72 6F 00", // "AirPods Pro"
|
||||
"41 33 30 34 38 00", // "A3048"
|
||||
"41 70 70 6C 65 20 49 6E 63 2E 00", // "Apple Inc."
|
||||
"57 35 4A 37 4B 56 30 4E 30 34 00", // serial
|
||||
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 30 38 32 00", // firmware
|
||||
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 30 38 32 00",
|
||||
"31 2E 30 2E 30 00",
|
||||
"63 6F 6D 2E 61 70 70 6C 65 2E 61 63 63 65 73 73 6F 72 79 2E 75 70 64 61 74 65 72 2E 61 70 70 2E 37 31 00",
|
||||
"48 33 4B 4C 37 48 52 39 32 36 4A 59 00", // left serial
|
||||
"48 33 4B 4C 32 41 59 4C 32 36 4B 30 00", // right serial
|
||||
"38 34 35 34 34 38 30 00",
|
||||
"1F 3F B4 B7 E9 81 48 11 94 6B C2 6F 3C 5F 5A 34 0B AB 7E 42 AA BD F1 49 E3 A8 98 E7 81 D6 04 F5 68 1F",
|
||||
"31 36 39 37 34 38 30 32 31 31 00 31 36 39 37 34 38 30 32 31 31 00",
|
||||
)
|
||||
val info = profile.decodeDeviceInfo(msg)!!
|
||||
info.name shouldBe "AirPods Pro"
|
||||
info.modelNumber shouldBe "A3048"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class BatterySessionTests {
|
||||
@Test
|
||||
fun `in case - pods CHARGING, case NOT_CHARGING`() {
|
||||
val r = profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 02 01 64 01 01 04 01 64 01 01 08 01 52 02 01"))!!
|
||||
r[AapPodState.BatteryType.RIGHT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.CHARGING }
|
||||
r[AapPodState.BatteryType.LEFT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.CHARGING }
|
||||
r[AapPodState.BatteryType.CASE]!!.let { it.percent shouldBe 0.82f; it.charging shouldBe AapPodState.ChargingState.NOT_CHARGING }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of case - pods NOT_CHARGING, case DISCONNECTED`() {
|
||||
val r = profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 02 01 64 02 01 04 01 64 02 01 08 01 00 04 01"))!!
|
||||
r[AapPodState.BatteryType.RIGHT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.NOT_CHARGING }
|
||||
r[AapPodState.BatteryType.LEFT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.NOT_CHARGING }
|
||||
r[AapPodState.BatteryType.CASE]!!.charging shouldBe AapPodState.ChargingState.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back in case - pods CHARGING`() {
|
||||
val r = profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 04 01 64 01 01 02 01 64 01 01 08 01 51 02 01"))!!
|
||||
r[AapPodState.BatteryType.LEFT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.CHARGING }
|
||||
r[AapPodState.BatteryType.RIGHT]!!.let { it.percent shouldBe 1.0f; it.charging shouldBe AapPodState.ChargingState.CHARGING }
|
||||
r[AapPodState.BatteryType.CASE]!!.let { it.percent shouldBe 0.81f; it.charging shouldBe AapPodState.ChargingState.NOT_CHARGING }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private Keys ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `private key response - 47 bytes with IRK and ENC`() {
|
||||
val result = profile.decodePrivateKeyResponse(
|
||||
aapMessage(
|
||||
"04 00 04 00 31 00 02 " +
|
||||
"01 00 10 00 A0 94 D4 BB 03 29 03 27 99 39 14 8D C4 64 60 95 " +
|
||||
"04 00 10 00 80 45 5C F4 F7 74 39 B8 4F AF 74 C8 4E 02 17 B7"
|
||||
)
|
||||
)!!
|
||||
result.irk!!.size shouldBe 16
|
||||
result.irk!![0] shouldBe 0xA0.toByte()
|
||||
result.encKey!!.size shouldBe 16
|
||||
result.encKey!![0] shouldBe 0x80.toByte()
|
||||
}
|
||||
|
||||
// ── Settings Push ────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class SettingsSessionTests {
|
||||
@Test
|
||||
fun `ANC mode ON`() {
|
||||
val anc = decodeSetting<AapSetting.AncMode>("04 00 04 00 09 00 0D 02 00 00 00")
|
||||
anc.current shouldBe AapSetting.AncMode.Value.ON
|
||||
anc.supported shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `press hold duration DEFAULT`() {
|
||||
decodeSetting<AapSetting.PressHoldDuration>("04 00 04 00 09 00 18 00 00 00 00").value shouldBe AapSetting.PressHoldDuration.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `press speed DEFAULT`() {
|
||||
decodeSetting<AapSetting.PressSpeed>("04 00 04 00 09 00 17 00 00 00 00").value shouldBe AapSetting.PressSpeed.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `volume swipe ON`() {
|
||||
decodeSetting<AapSetting.VolumeSwipe>("04 00 04 00 09 00 25 01 00 00 00").enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `volume swipe length DEFAULT`() {
|
||||
decodeSetting<AapSetting.VolumeSwipeLength>("04 00 04 00 09 00 23 00 00 00 00").value shouldBe AapSetting.VolumeSwipeLength.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `tone volume 80`() {
|
||||
decodeSetting<AapSetting.ToneVolume>("04 00 04 00 09 00 1F 50 50 00 00").level shouldBe 0x50
|
||||
}
|
||||
|
||||
@Test fun `end call mute mic - subtype 0x00`() {
|
||||
decodeSetting<AapSetting.EndCallMuteMic>("04 00 04 00 09 00 24 00 03 00 00").let {
|
||||
it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS
|
||||
it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `conversational awareness OFF`() {
|
||||
decodeSetting<AapSetting.ConversationalAwareness>("04 00 04 00 09 00 28 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `personalized volume OFF`() {
|
||||
decodeSetting<AapSetting.PersonalizedVolume>("04 00 04 00 09 00 26 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `adaptive audio noise 50`() {
|
||||
decodeSetting<AapSetting.AdaptiveAudioNoise>("04 00 04 00 09 00 2E 32 00 00 00").level shouldBe 0x32
|
||||
}
|
||||
|
||||
@Test fun `NC one airpod OFF`() {
|
||||
decodeSetting<AapSetting.NcWithOneAirPod>("04 00 04 00 09 00 1B 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANC Mode Switching (verified audible) ────────────────
|
||||
|
||||
@Nested
|
||||
inner class AncModeSwitchingTests {
|
||||
@Test fun `device echoes TRANSPARENCY`() {
|
||||
decodeSetting<AapSetting.AncMode>("04 00 04 00 09 00 0D 03 00 00 00").current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test fun `device echoes OFF`() {
|
||||
decodeSetting<AapSetting.AncMode>("04 00 04 00 09 00 0D 01 00 00 00").current shouldBe AapSetting.AncMode.Value.OFF
|
||||
}
|
||||
}
|
||||
|
||||
// ── Primary Pod (0x08) — real captures ─────────────────────
|
||||
|
||||
@Nested
|
||||
inner class PrimaryPodSessionTests {
|
||||
@Test fun `primary pod RIGHT`() {
|
||||
decodeSetting<AapSetting.PrimaryPod>("04 00 04 00 08 00 02 00 01 00").pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ear Detection (0x06) — real captures ──────────────────
|
||||
|
||||
@Nested
|
||||
inner class EarDetectionSessionTests {
|
||||
@Test fun `both pods in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 02 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `pod taken from case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 01 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `one pod in ear, other in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 00 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `one pod in ear, other not in ear`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 00 01")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `both pods in ear`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 00 00")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `both pods not in ear (on desk)`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 01 01")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unhandled Messages ───────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class UnhandledMessageTests {
|
||||
@Test fun `cmd 0x002B init exchange`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 F3 2F 47 00 00 00 00 00 52 FD 1C 00 00 00 00 00 7C D1 56 70 3F 18 05 00 00 E1 EE 3E 00 00 00 00 00 39 1B 4C 60 45 94 09 00 00")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x35, 0x3E)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user