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, currentMode = ancMode.current,
supportedModes = ancMode.supported, supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) }, onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
) )
} }
@@ -236,13 +236,23 @@ fun AncModeSelector(
currentMode: AapSetting.AncMode.Value, currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>, supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit, onModeSelected: (AapSetting.AncMode.Value) -> Unit,
pendingMode: AapSetting.AncMode.Value? = null,
) { ) {
val displayMode = pendingMode ?: currentMode
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode -> supportedModes.forEachIndexed { index, mode ->
SegmentedButton( SegmentedButton(
selected = mode == currentMode, selected = mode == displayMode,
onClick = { onModeSelected(mode) }, onClick = { onModeSelected(mode) },
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size), 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 = { label = {
Text( Text(
text = when (mode) { text = when (mode) {
@@ -222,6 +222,7 @@ fun SinglePodsCard(
currentMode = ancMode.current, currentMode = ancMode.current,
supportedModes = ancMode.supported, supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) }, onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
) )
} }
@@ -93,18 +93,48 @@ data class PodDevice(
val isHeadsetBeingCharged: Boolean? val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged 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? 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? 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? 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? val isEitherPodInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isEitherPodInEar get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
val caseLidState: DualApplePods.LidState? val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState get() = (ble as? DualApplePods)?.caseLidState
@@ -137,6 +167,9 @@ data class PodDevice(
val ancMode: AapSetting.AncMode? val ancMode: AapSetting.AncMode?
get() = aap?.setting() get() = aap?.setting()
val pendingAncMode: AapSetting.AncMode.Value?
get() = aap?.pendingAncMode
val conversationalAwareness: AapSetting.ConversationalAwareness? val conversationalAwareness: AapSetting.ConversationalAwareness?
get() = aap?.setting() 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.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer 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.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import java.time.Instant import java.time.Instant
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -56,6 +58,11 @@ internal class AapConnection(
private val writeMutex = Mutex() private val writeMutex = Mutex()
private val framer = AapFramer() 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. * 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. * 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}") throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
} }
connectionScope = scope
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
try { try {
@@ -116,6 +124,10 @@ internal class AapConnection(
log(TAG) { "Disconnecting" } log(TAG) { "Disconnecting" }
readerJob?.cancel() readerJob?.cancel()
readerJob = null readerJob = null
ancDebounceJob?.cancel()
ancDebounceJob = null
pendingAncMode = null
connectionScope = null
cleanupSocket() cleanupSocket()
framer.reset() framer.reset()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
@@ -127,12 +139,37 @@ internal class AapConnection(
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}") 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) val bytes = profile.encodeCommand(command)
writeMutex.withLock { writeMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val sock = socket ?: throw IOException("Socket is null") val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes) sock.outputStream.write(bytes)
sock.outputStream.flush() sock.outputStream.flush()
if (command is AapCommand.SetAncMode) lastAncCommandSentAt = System.currentTimeMillis()
log(TAG) { "Sent command: $command (${bytes.size} bytes)" } log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
} }
} }
@@ -169,8 +206,13 @@ internal class AapConnection(
} catch (e: IOException) { } catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" } if (isActive) log(TAG, ERROR) { "Read error: $e" }
} finally { } finally {
ancDebounceJob?.cancel()
pendingAncMode = null
cleanupSocket() 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) // Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) -> 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()) _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting: ${key.simpleName} = $value" } 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 return
} }
@@ -14,12 +14,20 @@ data class AapPodState(
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(), val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
val batteries: Map<BatteryType, Battery> = emptyMap(), val batteries: Map<BatteryType, Battery> = emptyMap(),
val lastMessageAt: Instant? = null, val lastMessageAt: Instant? = null,
val pendingAncMode: AapSetting.AncMode.Value? = null,
) { ) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState = fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
copy(settings = settings + (key to value)) 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 // Battery — from AAP command 0x04, 1% granularity
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
@@ -101,4 +101,17 @@ sealed class AapSetting {
data class ConversationalAwarenessState( data class ConversationalAwarenessState(
val speaking: Boolean, val speaking: Boolean,
) : AapSetting() ) : 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_BATTERY = 0x0004
const val CMD_DEVICE_INFO = 0x001D const val CMD_DEVICE_INFO = 0x001D
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031 const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
const val CMD_EAR_DETECTION = 0x0006
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
// Setting IDs (first byte of settings command payload) // Setting IDs (first byte of settings command payload)
@@ -85,6 +86,15 @@ class DefaultAapDeviceProfile(
} }
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? { 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) // Conversation Awareness State is a separate command type (push-only)
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
if (message.payload.isEmpty()) return null 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) { protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON 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.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase 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.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.HasEarDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
@@ -176,6 +177,135 @@ class PodDeviceTest : BaseTest() {
device.isEitherPodInEar shouldBe true 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 @Test
fun `icon and label properties delegate to BLE`() { fun `icon and label properties delegate to BLE`() {
val device = PodDevice( val device = PodDevice(
@@ -180,4 +180,84 @@ class AapPodStateTest : BaseTest() {
state.isCaseCharging.shouldBeNull() state.isCaseCharging.shouldBeNull()
state.isHeadsetCharging.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() } @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 ─────────────────────────────────────────── // ── Edge Cases ───────────────────────────────────────────
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() } @Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
@@ -179,16 +179,40 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
// ── Unhandled Messages ─────────────────────────────────── // ── 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 @Nested
inner class UnhandledMessageTests { inner class UnhandledMessageTests {
@Test fun `cmd 0x002B init exchange`() { @Test fun `cmd 0x002B init exchange`() {
profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 E9 B4 03")).shouldBeNull() 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 @Test
fun `unknown settings IDs return null`() { fun `unknown settings IDs return null`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B) val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B)