mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AapSetting.EarDetection>()
|
||||
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) {
|
||||
|
||||
@@ -25,6 +25,10 @@ data class AapPodState(
|
||||
val aapEarDetection: AapSetting.EarDetection?
|
||||
get() = setting<AapSetting.EarDetection>()
|
||||
|
||||
// Primary pod identity — from AAP command 0x08
|
||||
val aapPrimaryPod: AapSetting.PrimaryPod?
|
||||
get() = setting<AapSetting.PrimaryPod>()
|
||||
|
||||
val isEitherPodInEar: Boolean?
|
||||
get() = aapEarDetection?.isEitherPodInEar
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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<KClass<out AapSetting>, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
@@ -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() }
|
||||
|
||||
+21
@@ -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 ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user