mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
feat: Decode AAP ear detection and stabilize ANC mode changes
Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit. Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment. Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -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() }
|
||||||
|
|||||||
+28
-4
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user