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:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent bd5c6bf858
commit 71638c13ab
12 changed files with 462 additions and 11 deletions
@@ -210,6 +210,7 @@ fun DualPodsCard(
currentMode = ancMode.current,
supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
@@ -236,13 +236,23 @@ fun AncModeSelector(
currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
pendingMode: AapSetting.AncMode.Value? = null,
) {
val displayMode = pendingMode ?: currentMode
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == currentMode,
selected = mode == displayMode,
onClick = { onModeSelected(mode) },
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
SegmentedButtonDefaults.colors()
},
label = {
Text(
text = when (mode) {
@@ -222,6 +222,7 @@ fun SinglePodsCard(
currentMode = ancMode.current,
supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
@@ -93,18 +93,48 @@ data class PodDevice(
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
// Ear detection
// Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; BLE bit 5 tells us which physical pod is primary.
val isLeftInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isLeftPodInEar
get() {
val earDetection = aap?.aapEarDetection
val primary = (ble as? DualApplePods)?.primaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
}
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
}
val isRightInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isRightPodInEar
get() {
val earDetection = aap?.aapEarDetection
val primary = (ble as? DualApplePods)?.primaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
}
return (ble as? HasEarDetectionDual)?.isRightPodInEar
}
val isBeingWorn: Boolean?
get() = (ble as? HasEarDetection)?.isBeingWorn
get() {
val earDetection = aap?.aapEarDetection
if (earDetection != null) {
return earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
&& earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
return (ble as? HasEarDetection)?.isBeingWorn
}
val isEitherPodInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isEitherPodInEar
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
@@ -137,6 +167,9 @@ data class PodDevice(
val ancMode: AapSetting.AncMode?
get() = aap?.setting()
val pendingAncMode: AapSetting.AncMode.Value?
get() = aap?.pendingAncMode
val conversationalAwareness: AapSetting.ConversationalAwareness?
get() = aap?.setting()
@@ -12,11 +12,13 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
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.KeyExchangeResult
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -56,6 +58,11 @@ internal class AapConnection(
private val writeMutex = Mutex()
private val framer = AapFramer()
private var pendingAncMode: AapSetting.AncMode.Value? = null
private var ancDebounceJob: Job? = null
private var connectionScope: CoroutineScope? = null
private var lastAncCommandSentAt: Long = 0L
/**
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
* Returns after the handshake is sent — the read loop runs in [scope] independently.
@@ -65,6 +72,7 @@ internal class AapConnection(
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
}
connectionScope = scope
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
try {
@@ -116,6 +124,10 @@ internal class AapConnection(
log(TAG) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
ancDebounceJob?.cancel()
ancDebounceJob = null
pendingAncMode = null
connectionScope = null
cleanupSocket()
framer.reset()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
@@ -127,12 +139,37 @@ internal class AapConnection(
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
}
if (command is AapCommand.SetAncMode) {
val earDetection = currentState.setting<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) {
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
pendingAncMode = command.mode
_state.value = currentState.copy(pendingAncMode = command.mode)
return
}
pendingAncMode = null
// Optimistically update UI — don't wait for device echo
val currentAnc = currentState.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = currentState
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode))
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
} else {
_state.value = currentState.copy(pendingAncMode = null)
}
}
sendRaw(command)
}
private suspend fun sendRaw(command: AapCommand) {
val bytes = profile.encodeCommand(command)
writeMutex.withLock {
withContext(Dispatchers.IO) {
val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes)
sock.outputStream.flush()
if (command is AapCommand.SetAncMode) lastAncCommandSentAt = System.currentTimeMillis()
log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
}
}
@@ -169,8 +206,13 @@ internal class AapConnection(
} catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" }
} finally {
ancDebounceJob?.cancel()
pendingAncMode = null
cleanupSocket()
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.DISCONNECTED)
_state.value = _state.value.copy(
connectionState = AapPodState.ConnectionState.DISCONNECTED,
pendingAncMode = null,
)
}
}
@@ -202,8 +244,46 @@ internal class AapConnection(
// Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) ->
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
if (value is AapSetting.AncMode) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val sinceLastCommand = System.currentTimeMillis() - lastAncCommandSentAt
if (isFirstAncMode || sinceLastCommand <= 3000L) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting: ${key.simpleName} = $value" }
} else {
ancDebounceJob?.cancel()
ancDebounceJob = connectionScope?.launch {
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" }
}
}
return
}
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting: ${key.simpleName} = $value" }
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
pendingAncMode?.let { mode ->
pendingAncMode = null
// Optimistic update — show target mode immediately, don't wait for device echo
val currentAnc = _state.value.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = _state.value
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = mode))
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
} else {
_state.value = _state.value.copy(pendingAncMode = null)
}
log(TAG) { "Pod in ear, sending queued ANC mode: $mode" }
connectionScope?.launch { sendRaw(AapCommand.SetAncMode(mode)) }
}
}
return
}
@@ -14,12 +14,20 @@ data class AapPodState(
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
val batteries: Map<BatteryType, Battery> = emptyMap(),
val lastMessageAt: Instant? = null,
val pendingAncMode: AapSetting.AncMode.Value? = null,
) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
copy(settings = settings + (key to value))
// Ear detection — from AAP command 0x06
val aapEarDetection: AapSetting.EarDetection?
get() = setting<AapSetting.EarDetection>()
val isEitherPodInEar: Boolean?
get() = aapEarDetection?.isEitherPodInEar
// Battery — from AAP command 0x04, 1% granularity
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
@@ -101,4 +101,17 @@ sealed class AapSetting {
data class ConversationalAwarenessState(
val speaking: Boolean,
) : AapSetting()
/** Per-pod placement reported by the device (command 0x06). */
data class EarDetection(
val primaryPod: PodPlacement,
val secondaryPod: PodPlacement,
) : AapSetting() {
enum class PodPlacement {
IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED,
}
val isEitherPodInEar: Boolean
get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR
}
}
@@ -21,6 +21,7 @@ class DefaultAapDeviceProfile(
const val CMD_BATTERY = 0x0004
const val CMD_DEVICE_INFO = 0x001D
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
const val CMD_EAR_DETECTION = 0x0006
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
// Setting IDs (first byte of settings command payload)
@@ -85,6 +86,15 @@ class DefaultAapDeviceProfile(
}
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
// Ear detection is a separate command type (push-only from device)
if (message.commandType == CMD_EAR_DETECTION) {
if (message.payload.size < 2) return null
return AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF),
secondaryPod = decodePodPlacement(message.payload[1].toInt() and 0xFF),
)
}
// Conversation Awareness State is a separate command type (push-only)
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
if (message.payload.isEmpty()) return null
@@ -237,6 +247,13 @@ class DefaultAapDeviceProfile(
)
}
private fun decodePodPlacement(wireValue: Int): AapSetting.EarDetection.PodPlacement = when (wireValue) {
0x00 -> AapSetting.EarDetection.PodPlacement.IN_EAR
0x01 -> AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
0x02 -> AapSetting.EarDetection.PodPlacement.IN_CASE
else -> AapSetting.EarDetection.PodPlacement.DISCONNECTED
}
protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON
@@ -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
}
}
@@ -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() }
@@ -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)