fix(aap): Use compact write format for EndCallMuteMic

Real AirPod firmware silently drops writes with the LibrePods-documented 0x21 subtype. Switch to the compact 0x20 format that matches what devices actually emit in their echo frames. Also enforce the complementary-pair invariant at the SetEndCallMuteMic command boundary so validation runs before the optimistic UI update.
This commit is contained in:
darken
2026-04-14 17:38:08 +02:00
committed by Matthias Urhahn
parent 1a0bfbb3f4
commit f3b540987d
3 changed files with 104 additions and 16 deletions
@@ -12,7 +12,19 @@ sealed class AapCommand {
data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand()
data class SetToneVolume(val level: Int) : AapCommand()
data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand()
data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand()
data class SetEndCallMuteMic(
val muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
val endCall: AapSetting.EndCallMuteMic.EndCallMode,
) : AapCommand() {
init {
require(
(muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS &&
endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS) ||
(muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS &&
endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS)
) { "muteMic and endCall must be complementary press actions" }
}
}
data class SetVolumeSwipe(val enabled: Boolean) : AapCommand()
data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand()
data class SetAdaptiveAudioNoise(val level: Int) : AapCommand()
@@ -407,21 +407,41 @@ class DefaultAapDeviceProfile(
0x00, 0x00, 0x00,
)
/** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */
private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
SETTING_END_CALL_MUTE_MIC.toByte(), 0x21,
muteMic.wireValue.toByte(), endCall.wireValue.toByte(),
0x00,
)
/**
* EndCallMuteMic write uses compact format [0x24] [0x20] [combined] [0x00] [0x00].
* 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.
*/
private fun buildEndCallMuteMicMessage(
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
endCall: AapSetting.EndCallMuteMic.EndCallMode,
): ByteArray {
val combined = when {
muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS &&
endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x02
muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS &&
endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x03
else -> error("SetEndCallMuteMic invariant violated (validated by AapCommand.init)")
}
return byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
SETTING_END_CALL_MUTE_MIC.toByte(), 0x20,
combined.toByte(),
0x00, 0x00,
)
}
private fun decodeEndCallMuteMic(payload: ByteArray): Pair<KClass<out AapSetting>, AapSetting>? {
if (payload.size < 4) return null
val subType = payload[1].toInt() and 0xFF
return when (subType) {
0x21 -> {
// Standard format: byte 2 = muteMic, byte 3 = endCall
// Legacy doc-sourced format (LibrePods/MagicPodsCore). Never observed in real captures.
// Kept for forward-compat: if any unknown firmware emits this, decode still works.
// NOTE: writes must use compact 0x20 format — real devices silently ignore 0x21.
val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null
val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
@@ -7,6 +7,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.BaseAapSessionTest
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
@@ -270,12 +271,67 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
@Nested
inner class EndCallMuteMicTests {
@Test
fun `encode single press mute, double press end call`() {
val bytes = profile.encodeCommand(AapCommand.SetEndCallMuteMic(AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS))
bytes[6] shouldBe 0x24.toByte()
bytes[7] shouldBe 0x21.toByte()
bytes[8] shouldBe 0x23.toByte()
bytes[9] shouldBe 0x02.toByte()
fun `encode single press mute, double press end call (compact 0x20)`() {
val bytes = profile.encodeCommand(
AapCommand.SetEndCallMuteMic(
AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS,
AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS,
)
)
bytes shouldBe byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
0x24, 0x20,
0x02,
0x00, 0x00,
)
}
@Test
fun `encode double press mute, single press end call (compact 0x20)`() {
val bytes = profile.encodeCommand(
AapCommand.SetEndCallMuteMic(
AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
)
)
bytes shouldBe byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
0x24, 0x20,
0x03,
0x00, 0x00,
)
}
@Test
fun `encoded bytes round-trip through decoder`() {
listOf(
AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS,
AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
).forEach { (mute, end) ->
val encoded = profile.encodeCommand(AapCommand.SetEndCallMuteMic(mute, end))
val message = AapMessage.parse(encoded) ?: error("Failed to parse encoded bytes")
val decoded = decodeSetting<AapSetting.EndCallMuteMic>(message)
decoded.muteMic shouldBe mute
decoded.endCall shouldBe end
}
}
@Test
fun `SetEndCallMuteMic rejects non-complementary combination`() {
shouldThrow<IllegalArgumentException> {
AapCommand.SetEndCallMuteMic(
AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS,
AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
)
}
shouldThrow<IllegalArgumentException> {
AapCommand.SetEndCallMuteMic(
AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS,
)
}
}
@Test