mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+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