mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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()
|
||||
|
||||
+29
-9
@@ -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)
|
||||
|
||||
+62
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user