feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection

This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent dc2e318374
commit fef6c97d2d
9 changed files with 269 additions and 8 deletions
@@ -93,12 +93,21 @@ data class PodDevice(
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
when (aapPod) {
AapSetting.PrimaryPod.Pod.LEFT -> DualBlePodSnapshot.Pod.LEFT
AapSetting.PrimaryPod.Pod.RIGHT -> DualBlePodSnapshot.Pod.RIGHT
}
} ?: (ble as? DualApplePods)?.primaryPod
// Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; BLE bit 5 tells us which physical pod is primary.
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
val isLeftInEar: Boolean?
get() {
val earDetection = aap?.aapEarDetection
val primary = (ble as? DualApplePods)?.primaryPod
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
@@ -112,7 +121,7 @@ data class PodDevice(
val isRightInEar: Boolean?
get() {
val earDetection = aap?.aapEarDetection
val primary = (ble as? DualApplePods)?.primaryPod
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
@@ -139,12 +148,18 @@ data class PodDevice(
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
// Microphone
// Microphone — AAP preferred (from primary pod identity), BLE fallback
val isLeftPodMicrophone: Boolean?
get() = (ble as? HasDualMicrophone)?.isLeftPodMicrophone
get() {
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.LEFT }
return (ble as? HasDualMicrophone)?.isLeftPodMicrophone
}
val isRightPodMicrophone: Boolean?
get() = (ble as? HasDualMicrophone)?.isRightPodMicrophone
get() {
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.RIGHT }
return (ble as? HasDualMicrophone)?.isRightPodMicrophone
}
// Icons / labels
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
@@ -264,8 +264,18 @@ internal class AapConnection(
return
}
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting: ${key.simpleName} = $value" }
// Detect ear detection role swap — clear stale PrimaryPod until 0x0008 refreshes it
val clearPrimaryPod = value is AapSetting.EarDetection && run {
val prev = _state.value.setting<AapSetting.EarDetection>()
prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod
}
var newState = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
if (clearPrimaryPod) {
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
}
_state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" }
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
@@ -25,6 +25,10 @@ data class AapPodState(
val aapEarDetection: AapSetting.EarDetection?
get() = setting<AapSetting.EarDetection>()
// Primary pod identity — from AAP command 0x08
val aapPrimaryPod: AapSetting.PrimaryPod?
get() = setting<AapSetting.PrimaryPod>()
val isEitherPodInEar: Boolean?
get() = aapEarDetection?.isEitherPodInEar
@@ -114,4 +114,11 @@ sealed class AapSetting {
val isEitherPodInEar: Boolean
get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR
}
/** Which physical pod currently holds the microphone (command 0x08). */
data class PrimaryPod(
val pod: Pod,
) : AapSetting() {
enum class Pod { LEFT, RIGHT }
}
}
@@ -22,6 +22,7 @@ class DefaultAapDeviceProfile(
const val CMD_DEVICE_INFO = 0x001D
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
const val CMD_EAR_DETECTION = 0x0006
const val CMD_PRIMARY_POD = 0x0008
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
// Setting IDs (first byte of settings command payload)
@@ -86,6 +87,21 @@ class DefaultAapDeviceProfile(
}
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
// Primary pod identity (push-only, fires on mic/primary swap)
if (message.commandType == CMD_PRIMARY_POD) {
if (message.payload.size < 4) return null
val podId = message.payload[0].toInt() and 0xFF
// Validate known fixed bytes: [podId] 00 01 [01|00]
if ((message.payload[1].toInt() and 0xFF) != 0x00) return null
if ((message.payload[2].toInt() and 0xFF) != 0x01) return null
val pod = when (podId) {
0x01 -> AapSetting.PrimaryPod.Pod.LEFT
0x02 -> AapSetting.PrimaryPod.Pod.RIGHT
else -> return null
}
return AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(pod)
}
// Ear detection is a separate command type (push-only from device)
if (message.commandType == CMD_EAR_DETECTION) {
if (message.payload.size < 2) return null