Skip to content
Merged
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 @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down