mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(aap): Correct EndCallMuteMic mapping based on real-device behavior
Swap the logical mapping for `EndCallMuteMic` settings to align with hardware states observed on AirPods Pro devices. Updated session tests for Pro 1, 2, and 3 to reflect the corrected protocol behavior.
This commit is contained in:
+7
-5
@@ -409,7 +409,9 @@ class DefaultAapDeviceProfile(
|
||||
* The LibrePods-documented 0x21 "standard" format is silently ignored by real firmware
|
||||
* — no captured session (Pro 1, Pro 2 USB-C, Pro 3) has ever emitted it. Real devices
|
||||
* emit subtype 0x20 or 0x00; writes using 0x20 are accepted and persisted.
|
||||
* Combined-byte mapping mirrors decodeEndCallMuteMic's compact branch.
|
||||
* Combined-byte mapping is based on real-device correlation, not the older third-party docs:
|
||||
* on AirPods Pro 2 USB-C, compact value 0x03 matched the macOS UI state
|
||||
* "Single press to mute microphone / Double press to end call" (2026-04-15 capture).
|
||||
*/
|
||||
private fun buildEndCallMuteMicMessage(
|
||||
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
|
||||
@@ -417,9 +419,9 @@ class DefaultAapDeviceProfile(
|
||||
): ByteArray {
|
||||
val combined = when {
|
||||
muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS &&
|
||||
endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x02
|
||||
endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x03
|
||||
muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS &&
|
||||
endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x03
|
||||
endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x02
|
||||
else -> error("SetEndCallMuteMic invariant violated (validated by AapCommand.init)")
|
||||
}
|
||||
return byteArrayOf(
|
||||
@@ -449,8 +451,8 @@ class DefaultAapDeviceProfile(
|
||||
// 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
|
||||
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
else -> return null
|
||||
}
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
|
||||
+16
-17
@@ -10,7 +10,6 @@ import eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -132,7 +131,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
fun `round-trip all modes`() {
|
||||
for (mode in AapSetting.AncMode.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetAncMode(mode))
|
||||
val decoded = decodeSetting<AapSetting.AncMode>(AapMessage.Companion.parse(encoded)!!)
|
||||
val decoded = decodeSetting<AapSetting.AncMode>(AapMessage.parse(encoded)!!)
|
||||
decoded.current shouldBe mode
|
||||
}
|
||||
}
|
||||
@@ -166,7 +165,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
fun `round-trip all values`() {
|
||||
for (v in AapSetting.PressSpeed.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetPressSpeed(v))
|
||||
decodeSetting<AapSetting.PressSpeed>(AapMessage.Companion.parse(encoded)!!).value shouldBe v
|
||||
decodeSetting<AapSetting.PressSpeed>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +183,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
fun `round-trip all values`() {
|
||||
for (v in AapSetting.PressHoldDuration.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetPressHoldDuration(v))
|
||||
decodeSetting<AapSetting.PressHoldDuration>(AapMessage.Companion.parse(encoded)!!).value shouldBe v
|
||||
decodeSetting<AapSetting.PressHoldDuration>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +223,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
fun `round-trip all values`() {
|
||||
for (v in AapSetting.VolumeSwipeLength.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetVolumeSwipeLength(v))
|
||||
decodeSetting<AapSetting.VolumeSwipeLength>(AapMessage.Companion.parse(encoded)!!).value shouldBe v
|
||||
decodeSetting<AapSetting.VolumeSwipeLength>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,7 +258,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
@Test fun `encode decode round-trip`() {
|
||||
for (ui in listOf(0, 25, 50, 75, 100)) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(ui))
|
||||
decodeSetting<AapSetting.AdaptiveAudioNoise>(AapMessage.Companion.parse(encoded)!!).level shouldBe ui
|
||||
decodeSetting<AapSetting.AdaptiveAudioNoise>(AapMessage.parse(encoded)!!).level shouldBe ui
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +279,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
0x24, 0x20,
|
||||
0x02,
|
||||
0x03,
|
||||
0x00, 0x00,
|
||||
)
|
||||
}
|
||||
@@ -297,7 +296,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
0x24, 0x20,
|
||||
0x03,
|
||||
0x02,
|
||||
0x00, 0x00,
|
||||
)
|
||||
}
|
||||
@@ -342,22 +341,22 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
@Test
|
||||
fun `decode compact format 0x20 combined 0x02`() {
|
||||
val ecm = decodeSetting<AapSetting.EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 02 00 00"))
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode compact format 0x20 combined 0x03`() {
|
||||
val ecm = decodeSetting<AapSetting.EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 03 00 00"))
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode compact format subtype 0x00 from real Pro 3`() {
|
||||
val ecm = decodeSetting<AapSetting.EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 00 03 00 00"))
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS
|
||||
ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -462,7 +461,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
val enc = ByteArray(16) { 0x22.toByte() }
|
||||
val payload = byteArrayOf(0x02, 0x01, 0x00, 16, 0x00, *irk, 0x04, 0x00, 16, 0x00, *enc)
|
||||
val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00) + payload
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.Companion.parse(raw)!!)!!
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!!
|
||||
result.irk shouldBe irk
|
||||
result.encKey shouldBe enc
|
||||
}
|
||||
@@ -471,7 +470,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
fun `decode response with only IRK`() {
|
||||
val irk = ByteArray(16) { 0xAA.toByte() }
|
||||
val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00, 0x01, 0x01, 0x00, 16, 0x00, *irk)
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.Companion.parse(raw)!!)!!
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!!
|
||||
result.irk shouldBe irk
|
||||
result.encKey.shouldBeNull()
|
||||
}
|
||||
@@ -573,4 +572,4 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
|
||||
@Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() }
|
||||
@Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() }
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -142,8 +142,8 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
@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
|
||||
it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS
|
||||
it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x3E)
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull()
|
||||
}
|
||||
|
||||
+3
-3
@@ -134,7 +134,7 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
}
|
||||
|
||||
@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 }
|
||||
decodeSetting<AapSetting.EndCallMuteMic>("04 00 04 00 09 00 24 00 03 00 00").let { it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS; it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS }
|
||||
}
|
||||
|
||||
@Test fun `conversational awareness OFF`() {
|
||||
@@ -237,10 +237,10 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x3E, 0x37, 0x38, 0x3B)
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -137,8 +137,8 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
@Test fun `end call mute mic - subtype 0x20`() {
|
||||
decodeSetting<AapSetting.EndCallMuteMic>("04 00 04 00 09 00 24 20 03 00 00").let {
|
||||
it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS
|
||||
it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS
|
||||
it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user