mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16: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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user