mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat: Decode AAP ear detection and stabilize ANC mode changes
Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit. Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment. Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback.
This commit is contained in:
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
@@ -176,6 +177,135 @@ class PodDeviceTest : BaseTest() {
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP ear detection preferred over BLE`() {
|
||||
val mock = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns false
|
||||
}
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ear detection falls back to BLE when no AAP ear data`() {
|
||||
val mock = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
|
||||
}
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP left in ear maps via BLE primaryPod LEFT`() {
|
||||
val mock = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { primaryPod } returns DualBlePodSnapshot.Pod.LEFT
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe true
|
||||
device.isRightInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP left in ear maps via BLE primaryPod RIGHT`() {
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe false
|
||||
device.isRightInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP isBeingWorn true when both pods in ear`() {
|
||||
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_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isBeingWorn shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP isBeingWorn false when one pod not in ear`() {
|
||||
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_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isBeingWorn shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode exposed from AAP state`() {
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode null when no AAP`() {
|
||||
val device = PodDevice(ble = mockDualPod(), aap = null)
|
||||
device.pendingAncMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode null when not set`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
device.pendingAncMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `icon and label properties delegate to BLE`() {
|
||||
val device = PodDevice(
|
||||
|
||||
@@ -180,4 +180,84 @@ class AapPodStateTest : BaseTest() {
|
||||
state.isCaseCharging.shouldBeNull()
|
||||
state.isHeadsetCharging.shouldBeNull()
|
||||
}
|
||||
|
||||
// ── Ear Detection ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `ear detection accessor returns typed setting`() {
|
||||
val state = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
),
|
||||
)
|
||||
state.aapEarDetection.shouldNotBeNull()
|
||||
state.aapEarDetection!!.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ear detection accessor null when missing`() {
|
||||
val state = AapPodState()
|
||||
state.aapEarDetection.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEitherPodInEar true when primary in ear`() {
|
||||
val state = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
),
|
||||
)
|
||||
state.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEitherPodInEar true when secondary in ear`() {
|
||||
val state = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
),
|
||||
),
|
||||
)
|
||||
state.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEitherPodInEar false when neither in ear`() {
|
||||
val state = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
),
|
||||
),
|
||||
)
|
||||
state.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEitherPodInEar null when no ear detection`() {
|
||||
val state = AapPodState()
|
||||
state.isEitherPodInEar.shouldBeNull()
|
||||
}
|
||||
|
||||
// ── Pending ANC Mode ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode defaults to null`() {
|
||||
AapPodState().pendingAncMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode preserved in copy`() {
|
||||
val state = AapPodState().copy(pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE)
|
||||
state.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -420,6 +420,60 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
@Test fun `non-key message returns null`() { profile.decodePrivateKeyResponse(settingsMessage(0x0D, 0x02)).shouldBeNull() }
|
||||
}
|
||||
|
||||
// ── Ear Detection (0x06) ───────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class EarDetectionTests {
|
||||
@Test fun `decode both pods in ear`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 00 00"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `decode both pods not in ear`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 01 01"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `decode both pods in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 02 02"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `decode primary in ear, secondary in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 00 02"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `decode primary not in ear, secondary in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 01 02"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `decode unknown wire value maps to DISCONNECTED`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>(aapMessage("04 00 04 00 06 00 FF 03"))
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.DISCONNECTED
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test fun `payload too short returns null`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 06 00 00")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test fun `does not interfere with settings decode`() {
|
||||
decodeSetting<AapSetting.AncMode>(settingsMessage(0x0D, 0x02)).current shouldBe AapSetting.AncMode.Value.ON
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edge Cases ───────────────────────────────────────────
|
||||
|
||||
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
|
||||
|
||||
+28
-4
@@ -179,16 +179,40 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
// ── Unhandled Messages ───────────────────────────────────
|
||||
|
||||
// ── Ear Detection (0x06) — real captures ──────────────────
|
||||
|
||||
@Nested
|
||||
inner class EarDetectionSessionTests {
|
||||
@Test fun `both pods in case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 02 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `pod taken from case`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 01 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `pod in ear`() {
|
||||
val ed = decodeSetting<AapSetting.EarDetection>("04 00 04 00 06 00 00 02")
|
||||
ed.primaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
ed.secondaryPod shouldBe AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
ed.isEitherPodInEar shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unhandled Messages ───────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class UnhandledMessageTests {
|
||||
@Test fun `cmd 0x002B init exchange`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 E9 B4 03")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test fun `cmd 0x0006 ear detection`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 06 00 02 02")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B)
|
||||
|
||||
Reference in New Issue
Block a user