From fef6c97d2d598c0411292da90b10d3e75203b216 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 31 Mar 2026 18:19:06 +0200 Subject: [PATCH] feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection --- .../eu/darken/capod/monitor/core/PodDevice.kt | 27 +++- .../pods/core/apple/aap/AapConnection.kt | 14 +- .../capod/pods/core/apple/aap/AapPodState.kt | 4 + .../core/apple/aap/protocol/AapSetting.kt | 7 + .../aap/protocol/DefaultAapDeviceProfile.kt | 16 +++ .../capod/monitor/core/PodDeviceTest.kt | 136 ++++++++++++++++++ .../pods/core/apple/aap/AapPodStateTest.kt | 19 +++ .../devices/DefaultAapDeviceProfileTest.kt | 33 +++++ .../airpods/AirPodsPro3AapSessionTest.kt | 21 +++ 9 files changed, 269 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 45e39621..176b8c24 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -93,12 +93,21 @@ data class PodDevice( val isHeadsetBeingCharged: Boolean? get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged + // Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback. + private val resolvedPrimaryPod: DualBlePodSnapshot.Pod? + get() = aap?.aapPrimaryPod?.pod?.let { aapPod -> + when (aapPod) { + AapSetting.PrimaryPod.Pod.LEFT -> DualBlePodSnapshot.Pod.LEFT + AapSetting.PrimaryPod.Pod.RIGHT -> DualBlePodSnapshot.Pod.RIGHT + } + } ?: (ble as? DualApplePods)?.primaryPod + // Ear detection — AAP preferred (lower latency), BLE fallback. - // AAP reports primary/secondary; BLE bit 5 tells us which physical pod is primary. + // AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary. val isLeftInEar: Boolean? get() { val earDetection = aap?.aapEarDetection - val primary = (ble as? DualApplePods)?.primaryPod + val primary = resolvedPrimaryPod if (earDetection != null && primary != null) { return if (primary == DualBlePodSnapshot.Pod.LEFT) { earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR @@ -112,7 +121,7 @@ data class PodDevice( val isRightInEar: Boolean? get() { val earDetection = aap?.aapEarDetection - val primary = (ble as? DualApplePods)?.primaryPod + val primary = resolvedPrimaryPod if (earDetection != null && primary != null) { return if (primary == DualBlePodSnapshot.Pod.RIGHT) { earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR @@ -139,12 +148,18 @@ data class PodDevice( val caseLidState: DualApplePods.LidState? get() = (ble as? DualApplePods)?.caseLidState - // Microphone + // Microphone — AAP preferred (from primary pod identity), BLE fallback val isLeftPodMicrophone: Boolean? - get() = (ble as? HasDualMicrophone)?.isLeftPodMicrophone + get() { + aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.LEFT } + return (ble as? HasDualMicrophone)?.isLeftPodMicrophone + } val isRightPodMicrophone: Boolean? - get() = (ble as? HasDualMicrophone)?.isRightPodMicrophone + get() { + aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.RIGHT } + return (ble as? HasDualMicrophone)?.isRightPodMicrophone + } // Icons / labels val iconRes: Int get() = ble?.iconRes ?: model.iconRes diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 7b9dbb14..aeac7011 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -264,8 +264,18 @@ internal class AapConnection( return } - _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) - log(TAG) { "Setting: ${key.simpleName} = $value" } + // Detect ear detection role swap — clear stale PrimaryPod until 0x0008 refreshes it + val clearPrimaryPod = value is AapSetting.EarDetection && run { + val prev = _state.value.setting() + prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod + } + + var newState = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) + if (clearPrimaryPod) { + newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class) + } + _state.value = newState + log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" } // Flush queued ANC command when a pod goes in ear if (value is AapSetting.EarDetection && value.isEitherPodInEar) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index 0d968670..d0813f1a 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -25,6 +25,10 @@ data class AapPodState( val aapEarDetection: AapSetting.EarDetection? get() = setting() + // Primary pod identity — from AAP command 0x08 + val aapPrimaryPod: AapSetting.PrimaryPod? + get() = setting() + val isEitherPodInEar: Boolean? get() = aapEarDetection?.isEitherPodInEar diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index 5db04766..61b920a2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -114,4 +114,11 @@ sealed class AapSetting { val isEitherPodInEar: Boolean get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR } + + /** Which physical pod currently holds the microphone (command 0x08). */ + data class PrimaryPod( + val pod: Pod, + ) : AapSetting() { + enum class Pod { LEFT, RIGHT } + } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index 72dee24b..f6db8ab5 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -22,6 +22,7 @@ class DefaultAapDeviceProfile( const val CMD_DEVICE_INFO = 0x001D const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031 const val CMD_EAR_DETECTION = 0x0006 + const val CMD_PRIMARY_POD = 0x0008 const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B // Setting IDs (first byte of settings command payload) @@ -86,6 +87,21 @@ class DefaultAapDeviceProfile( } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { + // Primary pod identity (push-only, fires on mic/primary swap) + if (message.commandType == CMD_PRIMARY_POD) { + if (message.payload.size < 4) return null + val podId = message.payload[0].toInt() and 0xFF + // Validate known fixed bytes: [podId] 00 01 [01|00] + if ((message.payload[1].toInt() and 0xFF) != 0x00) return null + if ((message.payload[2].toInt() and 0xFF) != 0x01) return null + val pod = when (podId) { + 0x01 -> AapSetting.PrimaryPod.Pod.LEFT + 0x02 -> AapSetting.PrimaryPod.Pod.RIGHT + else -> return null + } + return AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(pod) + } + // Ear detection is a separate command type (push-only from device) if (message.commandType == CMD_EAR_DETECTION) { if (message.payload.size < 2) return null diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 3ad30f0f..633ba268 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -283,6 +283,142 @@ class PodDeviceTest : BaseTest() { device.isBeingWorn shouldBe false } + // --- AAP Primary Pod ear detection + microphone tests --- + + @Test + fun `AAP ear detection maps via AAP primaryPod - no BLE primaryPod needed`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + // BLE primaryPod not set (relaxed mock returns default) + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftInEar shouldBe true + device.isRightInEar shouldBe false + } + + @Test + fun `AAP primaryPod preferred over BLE primaryPod for ear mapping`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { primaryPod } returns DualBlePodSnapshot.Pod.RIGHT // BLE says RIGHT + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), // AAP says LEFT + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftInEar shouldBe true // AAP wins + device.isRightInEar shouldBe false + } + + @Test + fun `ear detection falls back to BLE when AAP primaryPod missing`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { primaryPod } returns DualBlePodSnapshot.Pod.RIGHT + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + // No PrimaryPod setting — falls back to BLE + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftInEar shouldBe false + device.isRightInEar shouldBe true // BLE says RIGHT is primary + } + + @Test + fun `AAP microphone from primaryPod preferred over BLE`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { isLeftPodMicrophone } returns false + every { isRightPodMicrophone } returns true + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftPodMicrophone shouldBe true // AAP says LEFT + device.isRightPodMicrophone shouldBe false + } + + @Test + fun `microphone falls back to BLE when no AAP primaryPod`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { isLeftPodMicrophone } returns false + every { isRightPodMicrophone } returns true + } + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftPodMicrophone shouldBe false + device.isRightPodMicrophone shouldBe true // BLE fallback + } + + @Test + fun `both pods out - microphone still shows last primary`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.RIGHT), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftPodMicrophone shouldBe false + device.isRightPodMicrophone shouldBe true + } + + @Test + fun `both pods in case - microphone still shows last primary`() { + val mock = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + } + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), + ), + ) + val device = PodDevice(ble = mock, aap = aap) + device.isLeftPodMicrophone shouldBe true + device.isRightPodMicrophone shouldBe false + } + @Test fun `pendingAncMode exposed from AAP state`() { val aap = AapPodState( diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt index aa3048c9..5f248f0f 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt @@ -248,6 +248,25 @@ class AapPodStateTest : BaseTest() { state.isEitherPodInEar.shouldBeNull() } + // ── Primary Pod ─────────────────────────────────────────── + + @Test + fun `primary pod accessor returns stored PrimaryPod setting`() { + val state = AapPodState( + settings = mapOf( + AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), + ), + ) + state.aapPrimaryPod.shouldNotBeNull() + state.aapPrimaryPod!!.pod shouldBe AapSetting.PrimaryPod.Pod.LEFT + } + + @Test + fun `primary pod accessor null when missing`() { + val state = AapPodState() + state.aapPrimaryPod.shouldBeNull() + } + // ── Pending ANC Mode ──────────────────────────────────── @Test diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index d45a8a08..3fdc3cbe 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -474,6 +474,39 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { } } + // ── Primary Pod (0x08) ────────────────────────────────── + + @Nested + inner class PrimaryPodTests { + @Test fun `decode LEFT`() { + decodeSetting(aapMessage("04 00 04 00 08 00 01 00 01 01")).pod shouldBe AapSetting.PrimaryPod.Pod.LEFT + } + + @Test fun `decode RIGHT`() { + decodeSetting(aapMessage("04 00 04 00 08 00 02 00 01 00")).pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT + } + + @Test fun `truncated payload returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 08 00 01 00 01")).shouldBeNull() + } + + @Test fun `empty payload returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 08 00")).shouldBeNull() + } + + @Test fun `unknown podId returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 08 00 03 00 01 01")).shouldBeNull() + } + + @Test fun `wrong fixed byte 1 returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 08 00 01 01 01 01")).shouldBeNull() + } + + @Test fun `wrong fixed byte 2 returns null`() { + profile.decodeSetting(aapMessage("04 00 04 00 08 00 01 00 02 01")).shouldBeNull() + } + } + // ── Edge Cases ─────────────────────────────────────────── @Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 55f056f8..ddc68d34 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -177,6 +177,27 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { decodeSetting("04 00 04 00 4B 00 00").speaking shouldBe false } + // ── Primary Pod (0x08) — real captures ───────────────────── + + @Nested + inner class PrimaryPodSessionTests { + @Test fun `primary pod LEFT - swap started`() { + decodeSetting("04 00 04 00 08 00 01 00 01 01").pod shouldBe AapSetting.PrimaryPod.Pod.LEFT + } + + @Test fun `primary pod RIGHT - swap started`() { + decodeSetting("04 00 04 00 08 00 02 00 01 01").pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT + } + + @Test fun `primary pod LEFT - swap completed`() { + decodeSetting("04 00 04 00 08 00 01 00 01 00").pod shouldBe AapSetting.PrimaryPod.Pod.LEFT + } + + @Test fun `primary pod RIGHT - swap completed`() { + decodeSetting("04 00 04 00 08 00 02 00 01 00").pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT + } + } + // ── Unhandled Messages ─────────────────────────────────── // ── Ear Detection (0x06) — real captures ──────────────────