feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection

This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent dc2e318374
commit fef6c97d2d
9 changed files with 269 additions and 8 deletions
@@ -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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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(
@@ -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
@@ -474,6 +474,39 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
}
}
// ── Primary Pod (0x08) ──────────────────────────────────
@Nested
inner class PrimaryPodTests {
@Test fun `decode LEFT`() {
decodeSetting<AapSetting.PrimaryPod>(aapMessage("04 00 04 00 08 00 01 00 01 01")).pod shouldBe AapSetting.PrimaryPod.Pod.LEFT
}
@Test fun `decode RIGHT`() {
decodeSetting<AapSetting.PrimaryPod>(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() }
@@ -177,6 +177,27 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
decodeSetting<AapSetting.ConversationalAwarenessState>("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<AapSetting.PrimaryPod>("04 00 04 00 08 00 01 00 01 01").pod shouldBe AapSetting.PrimaryPod.Pod.LEFT
}
@Test fun `primary pod RIGHT - swap started`() {
decodeSetting<AapSetting.PrimaryPod>("04 00 04 00 08 00 02 00 01 01").pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT
}
@Test fun `primary pod LEFT - swap completed`() {
decodeSetting<AapSetting.PrimaryPod>("04 00 04 00 08 00 01 00 01 00").pod shouldBe AapSetting.PrimaryPod.Pod.LEFT
}
@Test fun `primary pod RIGHT - swap completed`() {
decodeSetting<AapSetting.PrimaryPod>("04 00 04 00 08 00 02 00 01 00").pod shouldBe AapSetting.PrimaryPod.Pod.RIGHT
}
}
// ── Unhandled Messages ───────────────────────────────────
// ── Ear Detection (0x06) — real captures ──────────────────