diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt new file mode 100644 index 00000000..532cdb22 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt @@ -0,0 +1,163 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import java.time.Instant +import kotlin.reflect.KClass + +internal data class AncRuntimeState( + val latestObservedAncMode: AapSetting.AncMode? = null, + val pendingDebouncedAnc: PendingDebouncedAnc? = null, +) + +internal data class PendingDebouncedAnc( + val key: KClass, + val value: AapSetting.AncMode, + val previous: AapSetting?, +) + +internal data class AncDecision( + val podState: AapPodState, + val runtimeState: AncRuntimeState, + val timerActions: List = emptyList(), + val logs: List = emptyList(), +) + +internal class AapAncController { + + fun onAncSetting( + podState: AapPodState, + runtimeState: AncRuntimeState, + key: KClass, + value: AapSetting.AncMode, + isRecentAncSend: Boolean, + now: Instant, + ): AncDecision { + val previous = podState.settings[key] + val updatedRuntime = runtimeState.copy(latestObservedAncMode = value) + val timerActions = mutableListOf() + timerActions += planAllowOffInferenceTimer(podState, updatedRuntime) + + val isFirstAncMode = podState.setting() == null + return if (isFirstAncMode || isRecentAncSend) { + timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce) + AncDecision( + podState = applyAncSetting(podState, key, value, now, isRecentAncSend), + runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null), + timerActions = timerActions, + logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"), + ) + } else { + timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L) + AncDecision( + podState = podState, + runtimeState = updatedRuntime.copy( + pendingDebouncedAnc = PendingDebouncedAnc(key = key, value = value, previous = previous), + ), + timerActions = timerActions, + ) + } + } + + fun onEarDetectionUpdated( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision = AncDecision( + podState = podState, + runtimeState = runtimeState, + timerActions = planAllowOffInferenceTimer(podState, runtimeState), + ) + + fun onAncDebounceTimerFired( + podState: AapPodState, + runtimeState: AncRuntimeState, + now: Instant, + isRecentAncSend: Boolean, + ): AncDecision { + val pending = runtimeState.pendingDebouncedAnc ?: return AncDecision(podState, runtimeState) + return AncDecision( + podState = applyAncSetting(podState, pending.key, pending.value, now, isRecentAncSend), + runtimeState = runtimeState.copy(pendingDebouncedAnc = null), + logs = listOf("Setting: ${pending.key.simpleName} = ${pending.value} [was: ${pending.previous}]"), + ) + } + + fun onAllowOffInferenceTimerFired( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision { + val latestAncMode = runtimeState.latestObservedAncMode ?: podState.setting() + val latestEarDetection = podState.setting() + val latestAllowOffOption = podState.setting() + if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && + latestEarDetection?.isEitherPodInEar == true && + latestAllowOffOption?.enabled != true + ) { + return AncDecision( + podState = podState.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = true), + ), + runtimeState = runtimeState, + logs = listOf( + "Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)", + ), + ) + } + return AncDecision(podState, runtimeState) + } + + fun onOffRejected( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision { + val prev = podState.setting() + if (prev == null || prev.enabled) { + return AncDecision( + podState = podState.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = false), + ), + runtimeState = runtimeState, + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)), + logs = listOf("Inferred: AllowOffOption = false (OFF mode rejected by device)"), + ) + } + return AncDecision( + podState = podState, + runtimeState = runtimeState, + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)), + ) + } + + private fun applyAncSetting( + podState: AapPodState, + key: KClass, + value: AapSetting.AncMode, + now: Instant, + isRecentAncSend: Boolean, + ): AapPodState { + val updated = podState.withSetting(key, value).copy(lastMessageAt = now) + return if (isRecentAncSend && updated.pendingAncMode == value.current) { + updated.copy(pendingAncMode = null) + } else { + updated + } + } + + private fun planAllowOffInferenceTimer( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): List { + val observedAncMode = runtimeState.latestObservedAncMode ?: podState.setting() + val earDetection = podState.setting() + val allowOffOption = podState.setting() + return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF && + earDetection?.isEitherPodInEar == true && + allowOffOption?.enabled != true + ) { + listOf(EngineTimerAction.Start(EngineTimerKey.AllowOffInference, 1500L)) + } else { + listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt new file mode 100644 index 00000000..fefe9e08 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt @@ -0,0 +1,53 @@ +package eu.darken.capod.pods.core.apple.aap + +/** + * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for + * issue #173 engraving discovery logging. + */ +internal object AapDeviceInfoDiagnostics { + + fun describeSegments(payload: ByteArray): List { + var start = 0 + while (start < payload.size) { + val b = payload[start].toInt() and 0xFF + if (b in 0x20..0x7E) break + start++ + } + if (start >= payload.size) return emptyList() + + val segments = mutableListOf() + var segIndex = 0 + var i = start + while (i < payload.size) { + while (i < payload.size && payload[i] == 0x00.toByte()) i++ + if (i >= payload.size) break + + val segStart = i + while (i < payload.size && payload[i] != 0x00.toByte()) i++ + val segBytes = payload.copyOfRange(segStart, i) + + val utf8: String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(segBytes)) + .toString() + } catch (_: java.nio.charset.CharacterCodingException) { + null + } + val hex = segBytes.joinToString("") { "%02X".format(it) } + + segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex) + segIndex++ + } + return segments + } +} + +internal data class DeviceInfoSegment( + val index: Int, + val offset: Int, + val length: Int, + val utf8: String?, + val hex: String, +) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt new file mode 100644 index 00000000..f55985f0 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt @@ -0,0 +1,32 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile +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.StemPressEvent +import kotlin.reflect.KClass + +internal sealed interface AapInboundUpdate { + data class StemPress(val event: StemPressEvent) : AapInboundUpdate + data class Battery(val batteries: Map) : AapInboundUpdate + data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate + data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate + data class Setting(val key: KClass, val value: AapSetting) : AapInboundUpdate +} + +internal class AapInboundInterpreter( + private val profile: AapDeviceProfile, +) { + fun decode(message: AapMessage): AapInboundUpdate? { + profile.decodeStemPress(message)?.let { return AapInboundUpdate.StemPress(it) } + profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) } + profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) } + profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) } + profile.decodeSetting(message)?.let { (key, value) -> + return AapInboundUpdate.Setting(key, value) + } + return null + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt new file mode 100644 index 00000000..dd06d537 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt @@ -0,0 +1,176 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.common.TimeSource +import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +internal data class VerificationState( + val command: AapCommand, + val attempt: Int = 0, +) + +internal data class OutboundRuntimeState( + val pendingCommands: List = emptyList(), + val verification: VerificationState? = null, +) + +internal data class OutboundDecision( + val podState: AapPodState, + val runtimeState: OutboundRuntimeState, + val commandsToSend: List = emptyList(), + val timerActions: List = emptyList(), + val logs: List = emptyList(), + val rejectedCommand: AapCommand? = null, +) + +internal class AapOutboundController( + timeSource: TimeSource, +) { + + private val coordinator = AapSettingsCoordinator(timeSource) + + fun onCommandRequested( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + command: AapCommand, + ): OutboundDecision { + if (command !is AapCommand.SetDeviceName) { + val earDetection = podState.setting() + if (earDetection != null && !earDetection.isEitherPodInEar) { + val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState) + return OutboundDecision( + podState = applyPendingSnapshot( + result.optimisticState ?: podState, + result.snapshot, + if (command is AapCommand.SetAncMode) command.mode else result.snapshot.pendingAncMode, + ), + runtimeState = runtimeState.copy(pendingCommands = result.pendingCommands), + logs = listOf("No pod in ear, queuing: ${command::class.simpleName}"), + ) + } + } + + var updatedPodState = podState + var updatedRuntimeState = runtimeState + if (command is AapCommand.SetAncMode) { + val result = coordinator.removeFromQueue(runtimeState.pendingCommands, AapCommand.SetAncMode::class) + updatedPodState = applyPendingSnapshot(updatedPodState, result.snapshot, command.mode) + updatedRuntimeState = updatedRuntimeState.copy(pendingCommands = result.pendingCommands) + } + + coordinator.optimisticUpdate(updatedPodState, command)?.let { updatedPodState = it } + + val verificationCheck = coordinator.verificationFor(command) + return OutboundDecision( + podState = updatedPodState, + runtimeState = updatedRuntimeState.copy( + verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) } + ?: updatedRuntimeState.verification, + ), + commandsToSend = listOf(command), + timerActions = if (verificationCheck != null) { + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + } else { + emptyList() + }, + ) + } + + fun onEarDetectionInEar( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val result = coordinator.flush(runtimeState.pendingCommands) + if (result.commands.isEmpty()) return OutboundDecision(podState, runtimeState.copy(pendingCommands = result.pendingCommands)) + + val ancCommand = result.commands.firstOrNull { it is AapCommand.SetAncMode } as? AapCommand.SetAncMode + val updatedPodState = applyPendingSnapshot( + podState, + result.snapshot, + ancCommand?.mode ?: podState.pendingAncMode, + ) + val toVerify = result.commands.firstOrNull { it is AapCommand.SetAncMode } ?: result.commands.lastOrNull() + val verificationCheck = toVerify?.let { coordinator.verificationFor(it) } + return OutboundDecision( + podState = updatedPodState, + runtimeState = runtimeState.copy( + pendingCommands = result.pendingCommands, + verification = if (verificationCheck != null) { + VerificationState(command = checkNotNull(toVerify), attempt = 0) + } else { + runtimeState.verification + }, + ), + commandsToSend = result.commands, + timerActions = if (verificationCheck != null) { + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + } else { + emptyList() + }, + logs = listOf("Pod in ear, flushing ${result.commands.size} queued commands"), + ) + } + + fun onVerificationTimerFired( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState) + val check = coordinator.verificationFor(verification.command) + ?: return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = null), + ) + + if (check(podState)) { + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + ) + } + + val ear = podState.setting() + if (ear != null && !ear.isEitherPodInEar) { + return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = null), + logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"), + ) + } + + if (verification.attempt == 0) { + return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), + commandsToSend = listOf(verification.command), + timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)), + logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), + ) + } + + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + logs = listOf("Rejected after retry: ${verification.command::class.simpleName}"), + rejectedCommand = verification.command, + ) + } + + private fun clearPendingForCommand( + podState: AapPodState, + command: AapCommand, + ): AapPodState = if (command is AapCommand.SetAncMode && podState.pendingAncMode == command.mode) { + podState.copy(pendingAncMode = null) + } else { + podState + } + + private fun applyPendingSnapshot( + podState: AapPodState, + snapshot: AapSettingsCoordinator.PendingSnapshot, + ancPendingMode: AapSetting.AncMode.Value?, + ): AapPodState = podState.copy( + pendingAncMode = ancPendingMode, + pendingSettingsCount = snapshot.count, + ) +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt index 7c1a6123..2cc00102 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt @@ -32,12 +32,10 @@ import kotlin.reflect.KClass * that delegates to this engine for send-path logic and incoming message processing. */ internal class AapSessionEngine( - private val profile: AapDeviceProfile, + profile: AapDeviceProfile, private val timeSource: TimeSource, ) { - // ── State ─────────────────────────────────────────────── - private val _state = MutableStateFlow(AapPodState()) val state: StateFlow = _state.asStateFlow() @@ -48,333 +46,399 @@ internal class AapSessionEngine( MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() - private val coordinator = AapSettingsCoordinator(timeSource) private val hidTracker = HidTracker { msg -> log(TAG) { msg } } + private val inboundInterpreter = AapInboundInterpreter(profile) + private val ancController = AapAncController() + private val outboundController = AapOutboundController(timeSource) + private val sendMutex = Mutex() + private val timerJobs = mutableMapOf() private var scope: CoroutineScope? = null - private var ancDebounceJob: Job? = null - private var allowOffInferenceJob: Job? = null - private var lastSentCommand: AapCommand? = null - private var lastSentAt: Long = 0L - /** Separate tracking for ANC sends — not overwritten by non-ANC commands during flush. */ - private var lastAncSentAt: Long = 0L - private var handshakeResponseReceived: Boolean = false - private var latestObservedAncMode: AapSetting.AncMode? = null - - /** Stored reference to the socket write callback — set on each [send] / flush call. */ private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null + private var runtimeState = EngineRuntimeState() - // ── Lifecycle ─────────────────────────────────────────── - - /** Begin a session: set scope, transition to CONNECTING. */ fun start(scope: CoroutineScope) { - this.scope = scope - _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) + dispatch(AapEngineEvent.SessionStarted(scope)) } - /** Called after handshake bytes are sent on the socket. */ fun onHandshakeSent() { - _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING) - handshakeResponseReceived = false + dispatch(AapEngineEvent.HandshakeSent) } - /** Idempotent reset: cancel all jobs, clear queue, reset state to DISCONNECTED. */ fun reset() { - ancDebounceJob?.cancel() - ancDebounceJob = null - cancelAllowOffOptionInference() - coordinator.clear() - hidTracker.flush() - hidTracker.reset() - scope = null - lastSentCommand = null - lastSentAt = 0L - lastAncSentAt = 0L - latestObservedAncMode = null - activeSendRaw = null - handshakeResponseReceived = false - _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) + dispatch(AapEngineEvent.ResetRequested) } - // ── Send path ─────────────────────────────────────────── - - suspend fun send(command: AapCommand, sendRaw: suspend (AapCommand) -> Unit) = sendMutex.withLock { - activeSendRaw = sendRaw - val currentState = _state.value - if (currentState.connectionState != AapPodState.ConnectionState.READY) { - throw IllegalStateException("Cannot send command in state ${currentState.connectionState}") - } - - // Ear-detection gating — defer all setting commands except SetDeviceName - if (command !is AapCommand.SetDeviceName) { - val earDetection = currentState.setting() - if (earDetection != null && !earDetection.isEitherPodInEar) { - log(TAG) { "No pod in ear, queuing: ${command::class.simpleName}" } - val (optimistic, snapshot) = coordinator.enqueue(command, currentState) - optimistic?.let { _state.value = it } - _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, - ) - return@withLock - } - } - - // Immediate send path (pods in ear, or SetDeviceName) - if (command is AapCommand.SetAncMode) { - val snapshot = coordinator.removeFromQueue(AapCommand.SetAncMode::class) - val currentAnc = currentState.setting() - if (currentAnc != null) { - _state.value = - currentState.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode)) - .copy(lastMessageAt = timeSource.now()) - } - _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, - ) - } - - val preSendDeviceInfo = currentState.deviceInfo - coordinator.optimisticUpdate(currentState, command)?.let { _state.value = it } - try { - wrappedSend(sendRaw, command) - } catch (e: Exception) { - if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) { - _state.value = _state.value.copy(deviceInfo = preSendDeviceInfo) - } - throw e - } - coordinator.startVerification( - command, - scope!!, - { _state.value }, - { cmd -> wrappedSend(sendRaw, cmd) }, - ::onVerificationOutcome - ) - } - - private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) { - sendRaw(command) - lastSentCommand = command - val now = timeSource.currentTimeMillis() - lastSentAt = now - if (command is AapCommand.SetAncMode) lastAncSentAt = now - } - - private fun onVerificationOutcome(outcome: AapSettingsCoordinator.VerificationOutcome) { - if (outcome is AapSettingsCoordinator.VerificationOutcome.Rejected) { - val command = outcome.command - if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { - cancelAllowOffOptionInference() - val prev = _state.value.setting() - if (prev == null || prev.enabled) { - _state.value = _state.value.withSetting( - AapSetting.AllowOffOption::class, - AapSetting.AllowOffOption(enabled = false), - ) - log(TAG) { "Inferred: AllowOffOption = false (OFF mode rejected by device)" } - } - } + suspend fun send(command: AapCommand, sendRaw: suspend (AapCommand) -> Unit) { + scope ?: throw IllegalStateException("Cannot send command without an active session scope") + sendMutex.withLock { + activeSendRaw = sendRaw + val decision = outboundController.onCommandRequested(_state.value, runtimeState.outbound, command) + applyOutboundDecisionInline(decision) } } - // ── Message processing ────────────────────────────────── - fun processMessage(message: AapMessage) { - // Suppress per-frame raw hex for 0x0017 — the HidTracker emits structured summaries instead. - // For all other commands, log at VERBOSE as before. + dispatch(AapEngineEvent.MessageReceived(message)) + } + + private fun dispatch(event: AapEngineEvent) { + when (event) { + is AapEngineEvent.SessionStarted -> { + scope = event.scope + runtimeState = runtimeState.copy(handshakeResponseReceived = false) + _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) + } + + AapEngineEvent.HandshakeSent -> { + runtimeState = runtimeState.copy(handshakeResponseReceived = false) + _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING) + } + + AapEngineEvent.ResetRequested -> { + cancelAllTimers() + hidTracker.flush() + hidTracker.reset() + scope = null + activeSendRaw = null + runtimeState = EngineRuntimeState() + _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) + } + + is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message) + is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update) + is AapEngineEvent.TimerFired -> handleTimerFired(event.key) + } + } + + private fun handleMessageReceived(message: AapMessage) { if (message.commandType != CMD_HID_DESCRIPTOR) { val hex = message.raw.joinToString(" ") { "%02X".format(it) } - log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" } - // Flush any pending HID batch summary before processing a non-HID message. + log(TAG, VERBOSE) { + "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" + } hidTracker.flush() } - // HANDSHAKING → READY: first non-settings-echo message means the device is talking. - // Must happen before any early returns so decoded messages (battery, stem, etc.) also trigger it. - if (!handshakeResponseReceived && message.commandType != 0x0009 - && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING + if (!runtimeState.handshakeResponseReceived && + message.commandType != CMD_SETTING && + _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING ) { - handshakeResponseReceived = true + runtimeState = runtimeState.copy(handshakeResponseReceived = true) _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY) log(TAG) { "Connection READY" } } - // Fast-path for HID descriptor frames (cmd 0x0017). During case transitions, 800+ frames - // arrive in ~20 seconds. Handle them before any profile.decode*() calls to avoid 800 wasted - // decode attempts. The HidTracker batches bulk frames and emits structured summaries. if (message.commandType == CMD_HID_DESCRIPTOR) { hidTracker.consume(message.payload) _state.value = _state.value.copy(lastMessageAt = timeSource.now()) return } - // Issue #173 diagnostic dump - if (message.commandType == 0x001D) { - val segments = describeDeviceInfoSegments(message.payload) - log(TAG, INFO) { "DeviceInfoDump #173: payload=${message.payload.size} bytes, segments=${segments.size}" } - segments.forEach { seg -> - val label = when (seg.index) { - 0 -> "name" - 1 -> "modelNumber" - 2 -> "manufacturer" - 3 -> "serialNumber" - 4 -> "firmwareVersion" - 5 -> "firmwareVersionDup" - 6 -> "protocolVersion" - 7 -> "updaterAppId" - 8 -> "leftEarbudSerial" - 9 -> "rightEarbudSerial" - 10 -> "buildNumber" - 11 -> "encryptedBlob" - 12 -> "timestamp" - else -> "unknown" + if (message.commandType == CMD_DEVICE_INFO) { + logDeviceInfoDiagnostics(message.payload) + } + + val update = inboundInterpreter.decode(message) + if (update != null) { + dispatch(AapEngineEvent.InboundUpdateDecoded(update)) + } else { + logUnhandledMessage(message) + } + } + + private fun handleInboundUpdate(update: AapInboundUpdate) { + when (update) { + is AapInboundUpdate.StemPress -> { + _stemPressEvents.tryEmit(update.event) + log(TAG) { "Stem press: ${update.event.pressType} ${update.event.bud}" } + } + + is AapInboundUpdate.Battery -> { + val valid = update.batteries.filterValues { + it.charging != AapPodState.ChargingState.DISCONNECTED } - val rendered = seg.utf8?.let { "\"$it\"" } ?: "" - log( TAG, INFO ) { - "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" - } - } - } - - // Stem press (transient event, not stored in state) - profile.decodeStemPress(message)?.let { event -> - _stemPressEvents.tryEmit(event) - log(TAG) { "Stem press: ${event.pressType} ${event.bud}" } - return - } - - // Battery - profile.decodeBattery(message)?.let { batteries -> - val valid = batteries.filterValues { it.charging != AapPodState.ChargingState.DISCONNECTED } - _state.value = _state.value.copy( - batteries = _state.value.batteries + valid, - lastMessageAt = timeSource.now(), - ) - log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } - return - } - - // Private key response - profile.decodePrivateKeyResponse(message)?.let { keys -> - log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" } - _state.value = _state.value.copy(lastMessageAt = timeSource.now()) - _keysReceived.tryEmit(keys) - return - } - - // Device info - profile.decodeDeviceInfo(message)?.let { info -> - _state.value = _state.value.copy(deviceInfo = info, lastMessageAt = timeSource.now()) - log(TAG) { "Device info: ${info.name} (${info.modelNumber})" } - return - } - - // Setting update - profile.decodeSetting(message)?.let { (key, value) -> - val previous = _state.value.settings[key] - - // ANC debounce - if (value is AapSetting.AncMode) { - latestObservedAncMode = value - syncAllowOffOptionInference() - val isFirstAncMode = _state.value.setting() == null - val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L - if (isFirstAncMode || recentAncSend) { - ancDebounceJob?.cancel() - _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) - log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" } - } else { - ancDebounceJob?.cancel() - ancDebounceJob = scope?.launch { - delay(1500L) - _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) - log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" } - } - } - return - } - - // Ear detection role swap - val clearPrimaryPod = value is AapSetting.EarDetection && run { - val prev = _state.value.setting() - prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod - } - - var newState = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.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 ""} [was: $previous]" } - if (value is AapSetting.EarDetection) syncAllowOffOptionInference() - - // Flush queued commands when pod goes in ear - if (value is AapSetting.EarDetection && value.isEitherPodInEar) { - val (commands, snapshot) = coordinator.flush() _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, + batteries = _state.value.batteries + valid, + lastMessageAt = timeSource.now(), ) - - if (commands.isNotEmpty()) { - val ancCmd = commands.firstOrNull { it is AapCommand.SetAncMode } as? AapCommand.SetAncMode - if (ancCmd != null) { - val currentAnc = _state.value.setting() - if (currentAnc != null) { - _state.value = _state.value.withSetting( - AapSetting.AncMode::class, - currentAnc.copy(current = ancCmd.mode) - ).copy(lastMessageAt = timeSource.now()) - } - } - - log(TAG) { "Pod in ear, flushing ${commands.size} queued commands" } - val sendFn = activeSendRaw - scope?.launch { - if (sendFn == null) { - log(TAG, ERROR) { "No sendRaw available for flush" } - return@launch - } - for (cmd in commands) { - try { - wrappedSend(sendFn, cmd) - } catch (e: Exception) { - log(TAG, ERROR) { "Flush failed at ${cmd::class.simpleName}: $e" } - break - } - } - // Verify ANC mode if it was in the batch (most important for divergence detection). - // Fall back to verifying the last command if no ANC was flushed. - val toVerify = commands.firstOrNull { it is AapCommand.SetAncMode } ?: commands.lastOrNull() - toVerify?.let { - coordinator.startVerification( - it, - this, - { _state.value }, - { cmd -> wrappedSend(sendFn, cmd) }, - ::onVerificationOutcome - ) - } - } + log(TAG) { + "Battery update: ${update.batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } } + is AapInboundUpdate.PrivateKeys -> { + log(TAG) { + "Private keys received: IRK=${update.result.irk != null}, ENC=${update.result.encKey != null}" + } + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + _keysReceived.tryEmit(update.result) + } + + is AapInboundUpdate.DeviceInfo -> { + _state.value = _state.value.copy(deviceInfo = update.info, lastMessageAt = timeSource.now()) + log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" } + } + + is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value) + } + } + + private fun handleSettingUpdate(key: KClass, value: AapSetting) { + if (value is AapSetting.AncMode) { + val decision = ancController.onAncSetting( + podState = _state.value, + runtimeState = runtimeState.anc, + key = key, + value = value, + isRecentAncSend = isRecentAncSend(), + now = timeSource.now(), + ) + applyAncDecision(decision) return } - val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } - val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt - val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none" + val previous = _state.value.settings[key] + val clearPrimaryPod = value is AapSetting.EarDetection && run { + val prev = _state.value.setting() + prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod + } - if (message.commandType == 0x0009 && message.payload.size >= 2) { + var newState = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.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 ""} [was: $previous]" + } + + if (value is AapSetting.EarDetection) { + applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc)) + if (value.isEitherPodInEar) { + applyOutboundDecisionAsync( + outboundController.onEarDetectionInEar(_state.value, runtimeState.outbound), + ) + } + } + } + + private fun handleTimerFired(key: EngineTimerKey) { + when (key) { + EngineTimerKey.AncDebounce -> { + val decision = ancController.onAncDebounceTimerFired( + podState = _state.value, + runtimeState = runtimeState.anc, + now = timeSource.now(), + isRecentAncSend = isRecentAncSend(), + ) + applyAncDecision(decision) + } + + EngineTimerKey.AllowOffInference -> { + applyAncDecision(ancController.onAllowOffInferenceTimerFired(_state.value, runtimeState.anc)) + } + + EngineTimerKey.Verification -> { + applyOutboundDecisionAsync( + outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), + ) + } + } + } + + private fun applyAncDecision(decision: AncDecision) { + _state.value = decision.podState + runtimeState = runtimeState.copy(anc = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + applyTimerActions(decision.timerActions) + } + + /** + * Apply the non-send side of a decision (state, runtime, logs) and prepare the send context. + * + * Returns `null` when there's nothing to send — either the decision carries no commands, or + * no transport is currently available. In both "nothing to send" paths, the decision's timer + * actions and rejection handling are applied before returning, so callers only need to worry + * about the send path itself. + */ + private fun applyDecisionStateAndPrepareSend(decision: OutboundDecision): OutboundSendContext? { + val previousState = _state.value + val previousVerification = runtimeState.outbound.verification + + _state.value = decision.podState + runtimeState = runtimeState.copy(outbound = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + + if (decision.commandsToSend.isEmpty()) { + applyTimerActions(decision.timerActions) + handleRejectedCommand(decision.rejectedCommand) + return null + } + + val sendFn = activeSendRaw + if (sendFn == null) { + log(TAG, ERROR) { "No sendRaw available for outbound send" } + restoreVerification(previousVerification) + return null + } + + return OutboundSendContext(sendFn, previousState, previousVerification, decision) + } + + private fun restoreVerification(previousVerification: VerificationState?) { + runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification)) + } + + /** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */ + private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) { + val ctx = applyDecisionStateAndPrepareSend(decision) ?: return + try { + sendCommands(ctx.sendFn, ctx.decision.commandsToSend, ctx.previousState) + applyTimerActions(ctx.decision.timerActions) + handleRejectedCommand(ctx.decision.rejectedCommand) + } catch (e: Exception) { + restoreVerification(ctx.previousVerification) + throw e + } + } + + /** Engine-initiated send (timer fired, ear-detection flush): launched on the engine's scope. */ + private fun applyOutboundDecisionAsync(decision: OutboundDecision) { + val ctx = applyDecisionStateAndPrepareSend(decision) ?: return + val currentScope = scope + if (currentScope == null) { + log(TAG, ERROR) { "No scope available for outbound async send" } + restoreVerification(ctx.previousVerification) + return + } + currentScope.launch { + try { + sendCommands(ctx.sendFn, ctx.decision.commandsToSend, ctx.previousState) + applyTimerActions(ctx.decision.timerActions) + handleRejectedCommand(ctx.decision.rejectedCommand) + } catch (_: Exception) { + restoreVerification(ctx.previousVerification) + } + } + } + + private suspend fun sendCommands( + sendFn: suspend (AapCommand) -> Unit, + commands: List, + previousState: AapPodState, + ) { + commands.forEach { command -> + try { + wrappedSend(sendFn, command) + } catch (e: Exception) { + if (command is AapCommand.SetDeviceName && previousState.deviceInfo != null) { + _state.value = _state.value.copy(deviceInfo = previousState.deviceInfo) + } + if (commands.size == 1) { + throw e + } + log(TAG, ERROR) { "Flush failed at ${command::class.simpleName}: $e" } + throw e + } + } + } + + private suspend fun wrappedSend(sendFn: suspend (AapCommand) -> Unit, command: AapCommand) { + sendFn(command) + val now = timeSource.currentTimeMillis() + runtimeState = runtimeState.copy( + lastSentCommand = command, + lastSentAtMs = now, + lastAncSentAtMs = if (command is AapCommand.SetAncMode) now else runtimeState.lastAncSentAtMs, + ) + } + + private fun handleRejectedCommand(command: AapCommand?) { + if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { + applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc)) + } + } + + private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) { + val currentScope = scope ?: return + timerJobs.remove(key)?.cancel() + timerJobs[key] = currentScope.launch { + delay(delayMs) + timerJobs.remove(key) + dispatch(AapEngineEvent.TimerFired(key)) + } + } + + private fun cancelTimer(key: EngineTimerKey) { + timerJobs.remove(key)?.cancel() + } + + private fun cancelAllTimers() { + timerJobs.values.forEach { it.cancel() } + timerJobs.clear() + } + + private fun applyTimerActions(actions: List) { + actions.forEach { action -> + when (action) { + is EngineTimerAction.Start -> scheduleTimer(action.key, action.delayMs) + is EngineTimerAction.Cancel -> cancelTimer(action.key) + } + } + } + + private fun isRecentAncSend(): Boolean = + runtimeState.lastAncSentAtMs > 0L && (timeSource.currentTimeMillis() - runtimeState.lastAncSentAtMs) <= 3000L + + private fun currentSendDebugInfo(): SendDebugInfo { + val sinceLastSend = + if (runtimeState.lastSentAtMs == 0L) -1L else timeSource.currentTimeMillis() - runtimeState.lastSentAtMs + val lastSend = runtimeState.lastSentCommand?.let { it::class.simpleName } ?: "none" + return SendDebugInfo(sinceLastSend = sinceLastSend, lastSend = lastSend) + } + + private fun logDeviceInfoDiagnostics(payload: ByteArray) { + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) + log(TAG, INFO) { + "DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}" + } + segments.forEach { seg -> + val label = when (seg.index) { + 0 -> "name" + 1 -> "modelNumber" + 2 -> "manufacturer" + 3 -> "serialNumber" + 4 -> "firmwareVersion" + 5 -> "firmwareVersionDup" + 6 -> "protocolVersion" + 7 -> "updaterAppId" + 8 -> "leftEarbudSerial" + 9 -> "rightEarbudSerial" + 10 -> "buildNumber" + 11 -> "encryptedBlob" + 12 -> "timestamp" + else -> "unknown" + } + val rendered = seg.utf8?.let { "\"$it\"" } ?: "" + log(TAG, INFO) { + "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" + } + } + } + + private fun logUnhandledMessage(message: AapMessage) { + val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } + val sendInfo = currentSendDebugInfo() + + if (message.commandType == CMD_SETTING && message.payload.size >= 2) { val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF val boolHint = value.appleBoolHint() val tailHex = if (message.payload.size > 2) { - message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) } + message.payload.copyOfRange(2, message.payload.size) + .joinToString(" ") { "%02X".format(it) } } else { "" } @@ -385,13 +449,13 @@ internal class AapSessionEngine( boolHint?.let { append(" appleBool=$it") } append(" payload=${message.payload.size}B") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") - append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend") + append(" sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}") } } return } - if (message.commandType == 0x000C && message.payload.size >= 6) { + if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) { val macRaw = message.payload.formatMac(reverse = false) val macReversed = message.payload.formatMac(reverse = true) val tailHex = if (message.payload.size > 6) { @@ -406,7 +470,7 @@ internal class AapSessionEngine( append(" payload=${message.payload.size}B") append(" macRaw=$macRaw macReversed=$macReversed") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") - append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend") + append(" sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}") } } return @@ -415,254 +479,73 @@ internal class AapSessionEngine( if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) { _state.value = _state.value.copy(lastMessageAt = timeSource.now()) log(TAG, VERBOSE) { - "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend" + "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } return } log(TAG, INFO) { - "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend" + "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } } - // ── Inference ─────────────────────────────────────────── - - private fun syncAllowOffOptionInference() { - cancelAllowOffOptionInference() - - val observedAncMode = latestObservedAncMode ?: _state.value.setting() ?: return - val earDetection = _state.value.setting() ?: return - val allowOffOption = _state.value.setting() - if (observedAncMode.current != AapSetting.AncMode.Value.OFF) return - if (!earDetection.isEitherPodInEar) return - if (allowOffOption?.enabled == true) return - - val inferenceScope = scope ?: return - allowOffInferenceJob = inferenceScope.launch { - delay(1500L) - - val latestAncMode = latestObservedAncMode ?: _state.value.setting() - val latestEarDetection = _state.value.setting() - val latestAllowOffOption = _state.value.setting() - if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && - latestEarDetection?.isEitherPodInEar == true && - latestAllowOffOption?.enabled != true - ) { - _state.value = _state.value.withSetting( - AapSetting.AllowOffOption::class, - AapSetting.AllowOffOption(enabled = true), - ) - log(TAG) { - "Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)" - } - } - allowOffInferenceJob = null - } - } - - private fun cancelAllowOffOptionInference() { - allowOffInferenceJob?.cancel() - allowOffInferenceJob = null - } - companion object { private val TAG = logTag("AAP", "Engine") + private const val CMD_SETTING = 0x0009 + private const val CMD_CONNECTED_DEVICE = 0x000C + private const val CMD_DEVICE_INFO = 0x001D private const val CMD_HID_DESCRIPTOR = 0x0017 private val KNOWN_NON_SETTINGS_COMMANDS = setOf( 0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057, ) - - /** - * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the - * issue #173 engraving discovery logging. Not part of the production decode path — the - * production parser [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile] - * intentionally stays ASCII-only to preserve existing wire-format assumptions. - * - * Skips binary header bytes until the first printable ASCII byte (mirroring the production - * decoder's start condition — all known captures begin the real data with the device name, - * which is always ASCII like "AirPods Pro"), then splits on NUL bytes. Each non-empty chunk - * becomes one [DeviceInfoSegment]. UTF-8 decode is attempted strictly; if a chunk contains - * invalid UTF-8 (e.g. the post-serials encrypted blob), [DeviceInfoSegment.utf8] is null - * and the caller falls back to [DeviceInfoSegment.hex]. - * - * Offsets are relative to the supplied payload (post message-header), matching what the - * caller holds in [AapMessage.payload]. - */ - internal fun describeDeviceInfoSegments(payload: ByteArray): List { - var start = 0 - while (start < payload.size) { - val b = payload[start].toInt() and 0xFF - if (b in 0x20..0x7E) break - start++ - } - if (start >= payload.size) return emptyList() - - val segments = mutableListOf() - var segIndex = 0 - var i = start - while (i < payload.size) { - while (i < payload.size && payload[i] == 0x00.toByte()) i++ - if (i >= payload.size) break - - val segStart = i - while (i < payload.size && payload[i] != 0x00.toByte()) i++ - val segBytes = payload.copyOfRange(segStart, i) - - val utf8: String? = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) - .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) - .decode(java.nio.ByteBuffer.wrap(segBytes)) - .toString() - } catch (_: java.nio.charset.CharacterCodingException) { - null - } - val hex = segBytes.joinToString("") { "%02X".format(it) } - - segments.add(DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)) - segIndex++ - } - return segments - } - - internal data class DeviceInfoSegment( - val index: Int, - val offset: Int, - val length: Int, - val utf8: String?, - val hex: String, - ) } } -// ── HID descriptor frame tracker ─────────────────────── - /** - * Batches cmd 0x0017 HID descriptor frames and emits structured summaries. - * - * During case transitions AirPods send 800+ descriptor frames in ~20 seconds. - * Instead of logging each one, this tracker classifies each frame and batches - * consecutive bulk descriptor frames by (phase, fill), emitting a single summary - * line per batch. + * Synchronous engine events. User-initiated sends are intentionally not modeled here — they + * require a suspending transport callback and are handled directly in [AapSessionEngine.send]. */ -internal class HidTracker(private val log: (String) -> Unit) { - - private var bulkCount = 0 - private var bulkPhase: Int = -1 - private var bulkFill: Int = -1 - - fun consume(payload: ByteArray) { - when (val type = classify(payload)) { - is HidFrameType.ServiceDirectory -> { - flush() - val names = type.services.joinToString(", ") - log("HID: services=[$names] (${payload.size}B)") - } - - is HidFrameType.Descriptor -> { - if (type.phase != bulkPhase || type.fill != bulkFill) { - flush() - bulkPhase = type.phase - bulkFill = type.fill - } - bulkCount++ - } - - is HidFrameType.Terminator -> { - flush() - log("HID: terminator (${payload.size}B)") - } - - is HidFrameType.Other -> { - flush() - val hex = payload.joinToString(" ") { "%02X".format(it) } - log("HID: unknown (${payload.size}B) [$hex]") - } - } - } - - fun flush() { - if (bulkCount > 0) { - log("HID: $bulkCount descriptor frames phase=0x${"%02X".format(bulkPhase)} fill=0x${"%02X".format(bulkFill)}") - bulkCount = 0 - bulkPhase = -1 - bulkFill = -1 - } - } - - fun reset() { - bulkCount = 0 - bulkPhase = -1 - bulkFill = -1 - } - - internal sealed class HidFrameType { - data class ServiceDirectory(val services: List) : HidFrameType() - data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() - data class Terminator(val payloadSize: Int) : HidFrameType() - data class Other(val payloadSize: Int) : HidFrameType() - } - - internal companion object { - private val TERMINATOR = byteArrayOf(0x00, 0x04, 0x00, 0x00, 0x01, 0x00, 0xFF.toByte()) - private val DESCRIPTOR_PREFIX = byteArrayOf(0x00, 0x04, 0x00, 0x00, 0x44, 0x00, 0x01) - - internal fun classify(payload: ByteArray): HidFrameType { - if (payload.size == 7 && payload.contentEquals(TERMINATOR)) { - return HidFrameType.Terminator(payload.size) - } - - if (payload.size >= 10 && payload.startsWith(DESCRIPTOR_PREFIX)) { - val phase = payload[8].toInt() and 0xFF - val fill = payload[9].toInt() and 0xFF - return HidFrameType.Descriptor(phase, fill) - } - - if (payload.isNotEmpty() && (payload[0].toInt() and 0xFF) == 0xFE) { - val services = parseServiceNames(payload) - return HidFrameType.ServiceDirectory(services) - } - - return HidFrameType.Other(payload.size) - } - - /** - * Extract service names from a directory frame by scanning for runs of printable - * ASCII (0x20-0x7E) of length >= 2, starting from byte 4. The count at byte 3 - * is compared to the extracted names and a warning is logged on mismatch. - */ - private fun parseServiceNames(payload: ByteArray): List { - if (payload.size < 5) return emptyList() - - val names = mutableListOf() - var i = 4 - while (i < payload.size) { - val b = payload[i].toInt() and 0xFF - if (b in 0x20..0x7E) { - val start = i - while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++ - if (i - start >= 2) { - names.add(String(payload, start, i - start, Charsets.US_ASCII)) - } - } else { - i++ - } - } - return names - } - } +internal sealed interface AapEngineEvent { + data class SessionStarted(val scope: CoroutineScope) : AapEngineEvent + data object HandshakeSent : AapEngineEvent + data object ResetRequested : AapEngineEvent + data class MessageReceived(val message: AapMessage) : AapEngineEvent + data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent + data class TimerFired(val key: EngineTimerKey) : AapEngineEvent } -private fun ByteArray.startsWith(prefix: ByteArray): Boolean { - if (size < prefix.size) return false - for (i in prefix.indices) { - if (this[i] != prefix[i]) return false - } - return true +internal data class EngineRuntimeState( + val handshakeResponseReceived: Boolean = false, + val lastSentCommand: AapCommand? = null, + val lastSentAtMs: Long = 0L, + val lastAncSentAtMs: Long = 0L, + val outbound: OutboundRuntimeState = OutboundRuntimeState(), + val anc: AncRuntimeState = AncRuntimeState(), +) + +internal sealed interface EngineTimerKey { + data object AncDebounce : EngineTimerKey + data object AllowOffInference : EngineTimerKey + data object Verification : EngineTimerKey } -// ── Extension helpers ─────────────────────────────────── +internal sealed interface EngineTimerAction { + data class Start(val key: EngineTimerKey, val delayMs: Long) : EngineTimerAction + data class Cancel(val key: EngineTimerKey) : EngineTimerAction +} + +private data class OutboundSendContext( + val sendFn: suspend (AapCommand) -> Unit, + val previousState: AapPodState, + val previousVerification: VerificationState?, + val decision: OutboundDecision, +) + +private data class SendDebugInfo( + val sinceLastSend: Long, + val lastSend: String, +) /** Apple wire-format boolean: 0x01 = true, 0x02 = false. */ private fun Int.appleBoolHint(): Boolean? = when (this) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt index e954fa96..1be1e5ea 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt @@ -1,111 +1,93 @@ package eu.darken.capod.pods.core.apple.aap import eu.darken.capod.common.TimeSource -import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR -import eu.darken.capod.common.debug.logging.log -import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import kotlin.reflect.KClass -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** - * Coordinates deferred outbound settings and post-send verification. - * Does not own transport — [AapConnection] keeps socket and state mutation responsibility. - * - * All setting commands (except [AapCommand.SetDeviceName]) can be deferred here when no pod - * is in ear, then flushed as a batch when a pod goes in. + * Pure helper for queue ordering, optimistic state updates, and verification predicates. + * Runtime ownership (timers, retries, pending queue state) lives in [AapSessionEngine]. */ internal class AapSettingsCoordinator( private val timeSource: TimeSource, ) { - /** Snapshot of pending state — returned from every mutating method. */ data class PendingSnapshot( val pendingAncMode: AapSetting.AncMode.Value?, val count: Int, ) - /** Verification result — caller handles domain inference (e.g. AllowOffOption). */ - sealed class VerificationOutcome { - data object Confirmed : VerificationOutcome() - data class Rejected(val command: AapCommand) : VerificationOutcome() - } + data class QueueResult( + val pendingCommands: List, + val optimisticState: AapPodState?, + val snapshot: PendingSnapshot, + ) - // Keyed by command class — newer commands of the same type overwrite. - private val pendingCommands = linkedMapOf, AapCommand>() - private var verificationJob: Job? = null + data class FlushResult( + val commands: List, + val pendingCommands: List, + val snapshot: PendingSnapshot, + ) - // ── Queue operations ──────────────────────────────────── - - /** - * Queue a command. Returns (optimistic state update or null, pending snapshot). - * ANC uses [PendingSnapshot.pendingAncMode] for display, so no optimistic setting update. - * Handles stale dependency: removes [AapCommand.SetAdaptiveAudioNoise] when ANC leaves ADAPTIVE. - */ - fun enqueue(command: AapCommand, currentState: AapPodState): Pair { + fun enqueue( + pendingCommands: List, + command: AapCommand, + currentState: AapPodState, + ): QueueResult { + var updated = pendingCommands if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.ADAPTIVE) { - synchronized(pendingCommands) { pendingCommands.remove(AapCommand.SetAdaptiveAudioNoise::class) } + updated = updated.filterNot { it is AapCommand.SetAdaptiveAudioNoise } } - synchronized(pendingCommands) { pendingCommands[command::class] = command } + updated = updated.filterNot { it::class == command::class } + command - val optimistic = if (command !is AapCommand.SetAncMode) { - optimisticUpdate(currentState, command) - } else { - null - } - return optimistic to snapshot() + val optimistic = if (command !is AapCommand.SetAncMode) optimisticUpdate(currentState, command) else null + return QueueResult( + pendingCommands = updated, + optimisticState = optimistic, + snapshot = snapshot(updated), + ) } - /** Remove a specific command class from the queue. */ - fun removeFromQueue(commandClass: KClass): PendingSnapshot { - synchronized(pendingCommands) { pendingCommands.remove(commandClass) } - return snapshot() + fun removeFromQueue( + pendingCommands: List, + commandClass: KClass, + ): QueueResult { + val updated = pendingCommands.filterNot { it::class == commandClass } + return QueueResult( + pendingCommands = updated, + optimisticState = null, + snapshot = snapshot(updated), + ) } - /** Return sorted pending commands (ANC first) and clear queue. */ - fun flush(): Pair, PendingSnapshot> { - val commands: List - synchronized(pendingCommands) { - commands = pendingCommands.values.toList() - pendingCommands.clear() - } - // Dependency-aware ordering: - // AllowOffOption must precede AncMode (device rejects OFF without it) - // AncMode must precede mode-dependent settings (e.g. AdaptiveAudioNoise) - val sorted = commands.sortedBy { + fun flush(pendingCommands: List): FlushResult { + val sorted = pendingCommands.sortedBy { when (it) { is AapCommand.SetAllowOffOption -> 0 is AapCommand.SetAncMode -> 1 else -> 2 } } - return sorted to snapshot() + return FlushResult( + commands = sorted, + pendingCommands = emptyList(), + snapshot = snapshot(emptyList()), + ) } - /** Cancel verification, clear queue. */ - fun clear(): PendingSnapshot { - verificationJob?.cancel() - verificationJob = null - synchronized(pendingCommands) { pendingCommands.clear() } - return snapshot() + fun clear(): QueueResult = QueueResult( + pendingCommands = emptyList(), + optimisticState = null, + snapshot = snapshot(emptyList()), + ) + + fun snapshot(pendingCommands: List): PendingSnapshot { + val ancPending = pendingCommands.firstOrNull { it is AapCommand.SetAncMode } + ?.let { (it as AapCommand.SetAncMode).mode } + return PendingSnapshot(pendingAncMode = ancPending, count = pendingCommands.size) } - private fun snapshot(): PendingSnapshot { - val map = synchronized(pendingCommands) { pendingCommands.toMap() } - val ancPending = (map[AapCommand.SetAncMode::class] as? AapCommand.SetAncMode)?.mode - return PendingSnapshot(pendingAncMode = ancPending, count = map.size) - } - - // ── Optimistic update ─────────────────────────────────── - - /** - * Pure: compute the optimistic state for a command. - * Returns null when no update applies (ANC mode — uses pendingAncMode, or setting not yet reported). - */ fun optimisticUpdate(baseState: AapPodState, command: AapCommand): AapPodState? { val updated: Pair, AapSetting> = when (command) { is AapCommand.SetAncMode -> return null @@ -147,7 +129,10 @@ internal class AapSettingsCoordinator( } is AapCommand.SetEndCallMuteMic -> { baseState.setting() ?: return null - AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall) + AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic( + muteMic = command.muteMic, + endCall = command.endCall, + ) } is AapCommand.SetMicrophoneMode -> { AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode) @@ -169,17 +154,15 @@ internal class AapSettingsCoordinator( } is AapCommand.SetDeviceName -> { val currentInfo = baseState.deviceInfo ?: return null - return baseState.copy(deviceInfo = currentInfo.copy(name = command.name), lastMessageAt = timeSource.now()) + return baseState.copy( + deviceInfo = currentInfo.copy(name = command.name), + lastMessageAt = timeSource.now(), + ) } } return baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = timeSource.now()) } - // ── Verification ──────────────────────────────────────── - - /** - * Pure: return an echo-verification check lambda for a command, or null if no verification. - */ fun verificationFor(command: AapCommand): ((AapPodState) -> Boolean)? = when (command) { is AapCommand.SetAncMode -> { s -> s.setting()?.current == command.mode } is AapCommand.SetAdaptiveAudioNoise -> { s -> s.setting()?.level == command.level } @@ -203,56 +186,4 @@ internal class AapSettingsCoordinator( is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDeviceName -> null } - - /** - * Start a delayed verification for a sent command. - * After 1s, checks if the device echoed the expected state. - * On mismatch: resend once, then report [VerificationOutcome.Rejected]. - * Cancels any previous verification (single-flight). - */ - fun startVerification( - command: AapCommand, - scope: CoroutineScope, - stateProvider: () -> AapPodState, - sendRaw: suspend (AapCommand) -> Unit, - onOutcome: (VerificationOutcome) -> Unit, - ) { - val check = verificationFor(command) ?: return - verificationJob?.cancel() - verificationJob = scope.launch { - delay(1000L) - if (check(stateProvider())) { - onOutcome(VerificationOutcome.Confirmed) - return@launch - } - - // Abort if pods left ear — firmware is doing its own thing - val ear = stateProvider().setting() - if (ear != null && !ear.isEitherPodInEar) { - log(TAG) { "Verification aborted for ${command::class.simpleName}: no pod in ear" } - return@launch - } - - log(TAG) { "Divergence detected for ${command::class.simpleName}, re-sending" } - try { - sendRaw(command) - } catch (e: Exception) { - log(TAG, ERROR) { "Verification resend failed: $e" } - return@launch - } - - // Second check after retry - delay(1000L) - if (check(stateProvider())) { - onOutcome(VerificationOutcome.Confirmed) - } else { - log(TAG) { "Rejected after retry: ${command::class.simpleName}" } - onOutcome(VerificationOutcome.Rejected(command)) - } - } - } - - companion object { - private val TAG = logTag("AAP", "Coordinator") - } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt new file mode 100644 index 00000000..2c4e119b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt @@ -0,0 +1,114 @@ +package eu.darken.capod.pods.core.apple.aap + +/** + * Batches cmd 0x0017 HID descriptor frames and emits structured summaries. + * + * During case transitions AirPods send 800+ descriptor frames in ~20 seconds. + * Instead of logging each one, this tracker classifies each frame and batches + * consecutive bulk descriptor frames by (phase, fill), emitting a single summary + * line per batch. + */ +internal class HidTracker(private val log: (String) -> Unit) { + + private var bulkCount = 0 + private var bulkPhase: Int = -1 + private var bulkFill: Int = -1 + + fun consume(payload: ByteArray) { + when (val type = classify(payload)) { + is HidFrameType.ServiceDirectory -> { + flush() + val names = type.services.joinToString(", ") + log("HID: services=[$names] (${payload.size}B)") + } + is HidFrameType.Descriptor -> { + if (bulkCount == 0) { + bulkPhase = type.phase + bulkFill = type.fill + bulkCount = 1 + } else if (bulkPhase == type.phase && bulkFill == type.fill) { + bulkCount++ + } else { + flush() + bulkPhase = type.phase + bulkFill = type.fill + bulkCount = 1 + } + } + is HidFrameType.Terminator -> { + flush() + log("HID: terminator (${type.payloadSize}B)") + } + is HidFrameType.Other -> { + flush() + val hex = payload.joinToString(" ") { "%02X".format(it) } + log("HID: unknown (${payload.size}B) [$hex]") + } + } + } + + fun flush() { + if (bulkCount == 0) return + log( + "HID: $bulkCount descriptor frames phase=0x${"%02X".format(bulkPhase)} fill=0x${"%02X".format(bulkFill)}" + ) + bulkCount = 0 + bulkPhase = -1 + bulkFill = -1 + } + + fun reset() { + bulkCount = 0 + bulkPhase = -1 + bulkFill = -1 + } + + sealed class HidFrameType { + data class ServiceDirectory(val services: List) : HidFrameType() + data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() + data class Terminator(val payloadSize: Int) : HidFrameType() + data object Other : HidFrameType() + } + + companion object { + internal fun classify(payload: ByteArray): HidFrameType { + // Service directory frame — starts with FE 00 00 and contains repeated + // [len=4? ascii service name + 4B flags] blocks. For logging, extract names. + if (payload.size >= 4 && (payload[0].toInt() and 0xFF) == 0xFE) { + val services = mutableListOf() + var i = 4 + while (i + 7 < payload.size) { + val rawName = payload.copyOfRange(i, i + 4) + val name = rawName + .takeWhile { it != 0.toByte() } + .toByteArray() + .toString(Charsets.US_ASCII) + if (name.isNotBlank()) services += name + i += 8 + } + return HidFrameType.ServiceDirectory(services) + } + + // Bulk descriptor frames observed as: + // 00 04 00 00 44 00 01 A1 81 FF FF ... + // 00 04 00 00 44 00 01 C3 02 EF EF ... + if (payload.size >= 10 && + payload[0] == 0x00.toByte() && + payload[1] == 0x04.toByte() && + payload[4] == 0x44.toByte() && + payload[5] == 0x00.toByte() + ) { + val phase = payload[8].toInt() and 0xFF + val fill = payload[9].toInt() and 0xFF + return HidFrameType.Descriptor(phase = phase, fill = fill) + } + + // Small terminator frame observed as exactly 7 bytes ending in FF. + if (payload.size == 7 && payload.last() == 0xFF.toByte()) { + return HidFrameType.Terminator(payload.size) + } + + return HidFrameType.Other + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt new file mode 100644 index 00000000..54c23fce --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt @@ -0,0 +1,152 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class AapAncControllerTest : BaseTest() { + + private val controller = AapAncController() + private val now = Instant.ofEpochMilli(1000L) + private val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + + private fun podStateWith(vararg settings: Pair, AapSetting>): AapPodState = + AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = settings.toMap(), + ) + + @Test + fun `first in-ear OFF applies immediately and schedules AllowOff inference`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.podState.setting()!!.current shouldBe AapSetting.AncMode.Value.OFF + decision.runtimeState.pendingDebouncedAnc.shouldBeNull() + decision.timerActions shouldBe listOf( + EngineTimerAction.Start(EngineTimerKey.AllowOffInference, 1500L), + EngineTimerAction.Cancel(EngineTimerKey.AncDebounce), + ) + } + + @Test + fun `in-case OFF does not schedule AllowOff inference`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.timerActions shouldBe listOf( + EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference), + EngineTimerAction.Cancel(EngineTimerKey.AncDebounce), + ) + } + + @Test + fun `unsolicited ANC change is deferred through debounce state`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.AncMode::class to AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.podState.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + decision.runtimeState.pendingDebouncedAnc!!.value.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + decision.timerActions shouldBe listOf( + EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference), + EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L), + ) + } + + @Test + fun `AllowOff timer infers true from stable in-ear OFF`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.AncMode::class to AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + ) + + val decision = controller.onAllowOffInferenceTimerFired( + podState = podState, + runtimeState = AncRuntimeState( + latestObservedAncMode = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + ), + ) + + decision.podState.setting()!!.enabled shouldBe true + } + + @Test + fun `OFF rejection forces AllowOff false and cancels inference timer`() { + val podState = podStateWith( + AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true), + ) + + val decision = controller.onOffRejected(podState, AncRuntimeState()) + + decision.podState.setting()!!.enabled shouldBe false + decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt similarity index 92% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt index e899de00..270372de 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test import testhelpers.BaseTest /** - * Tests for the issue #173 diagnostic helper [AapSessionEngine.describeDeviceInfoSegments]. + * Tests for the issue #173 diagnostic helper [AapDeviceInfoDiagnostics.describeSegments]. * * The helper must: * - Work on real captured 0x1D payloads (matches production decoder on ASCII slots). @@ -18,7 +18,7 @@ import testhelpers.BaseTest * - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo] * would return null (malformed / unknown-shaped packets). */ -class AapConnectionTest : BaseTest() { +class AapDeviceInfoDiagnosticsTest : BaseTest() { private fun hex(s: String): ByteArray = s .replace("\n", " ") @@ -50,7 +50,7 @@ class AapConnectionTest : BaseTest() { """ ) - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) // The first five segments mirror the production decoder's name/modelNumber/manufacturer/ // serialNumber/firmwareVersion slots. @@ -81,7 +81,7 @@ class AapConnectionTest : BaseTest() { engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) + "A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) segments.size shouldBe 2 segments[0].utf8 shouldBe engraving @@ -101,7 +101,7 @@ class AapConnectionTest : BaseTest() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2_USBC).decodeDeviceInfo(message).shouldBeNull() - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) segments.size shouldBe 2 segments[0].utf8 shouldBe "Name" segments[1].utf8 shouldBe "Model" diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt index f9bd0e05..6fd944f1 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt @@ -117,6 +117,45 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED } + @Test + fun `reset cancels pending runtime timers`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.start(this as TestScope) + engine.onHandshakeSent() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.TRANSPARENCY, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + engine.reset() + advanceTimeBy(1600L) + + engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED + engine.state.value.setting().shouldBeNull() + } + @Test fun `first non-0x0009 message transitions HANDSHAKING to READY`() { val engine = createEngine() @@ -249,6 +288,7 @@ class AapSessionEngineTest : BaseTest() { sentCommands.size shouldBe 2 sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true) + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE nextSetting = AapSetting.AncMode::class as KClass to ancSetting.copy(current = AapSetting.AncMode.Value.ON) @@ -256,11 +296,13 @@ class AapSessionEngineTest : BaseTest() { // This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced. engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE advanceTimeBy(1100L) sentCommands.size shouldBe 3 sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE } } @@ -508,6 +550,7 @@ class AapSessionEngineTest : BaseTest() { engine.processMessage(dummyMessage()) advanceTimeBy(2100L) + engine.state.value.pendingAncMode.shouldBeNull() engine.state.value.setting()?.enabled shouldBe false sentCommands shouldBe listOf( AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), @@ -516,6 +559,50 @@ class AapSessionEngineTest : BaseTest() { } } + +@Test +fun `matching ANC echo clears pending mode without optimistic current overwrite`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it } + + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + engine.state.value.pendingAncMode.shouldBeNull() + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) +} + // ── ANC Debounce ──────────────────────────────────────── @Nested diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt index 66ab9a65..917df67a 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt @@ -12,14 +12,11 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import testhelpers.BaseTest import java.time.Instant +import kotlin.reflect.KClass class AapSettingsCoordinatorTest : BaseTest() { @@ -30,15 +27,13 @@ class AapSettingsCoordinatorTest : BaseTest() { private fun createCoordinator() = AapSettingsCoordinator(timeSource) - private fun stateWithSetting(vararg settings: Pair, AapSetting>): AapPodState { + private fun stateWithSetting(vararg settings: Pair, AapSetting>): AapPodState { val map = settings.toMap() return AapPodState(connectionState = AapPodState.ConnectionState.READY, settings = map) } - // ── Enqueue ───────────────────────────────────────────── - @Nested - inner class EnqueueTests { + inner class QueueTests { @Test fun `enqueue returns snapshot with correct count`() { @@ -47,22 +42,21 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled = false), ) - val (_, snapshot) = coord.enqueue(AapCommand.SetConversationalAwareness(true), state) + val result = coord.enqueue(emptyList(), AapCommand.SetConversationalAwareness(true), state) - snapshot.count shouldBe 1 - snapshot.pendingAncMode.shouldBeNull() + result.snapshot.count shouldBe 1 + result.snapshot.pendingAncMode.shouldBeNull() } @Test fun `enqueue ANC returns pendingAncMode in snapshot`() { val coord = createCoordinator() - val state = stateWithSetting() - val (optimistic, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val result = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting()) - optimistic.shouldBeNull() // ANC uses pendingAncMode, not optimistic update - snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE - snapshot.count shouldBe 1 + result.optimisticState.shouldBeNull() + result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + result.snapshot.count shouldBe 1 } @Test @@ -72,12 +66,12 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), ) - coord.enqueue(AapCommand.SetToneVolume(60), state) - val (optimistic, snapshot) = coord.enqueue(AapCommand.SetToneVolume(80), state) + val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(60), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetToneVolume(80), state) - snapshot.count shouldBe 1 // Not 2 - optimistic.shouldNotBeNull() - optimistic.setting()!!.level shouldBe 80 + second.snapshot.count shouldBe 1 + second.optimisticState.shouldNotBeNull() + second.optimisticState!!.setting()!!.level shouldBe 80 } @Test @@ -87,11 +81,11 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 50), ) - coord.enqueue(AapCommand.SetAdaptiveAudioNoise(70), state) - val (_, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - snapshot.count shouldBe 1 // Only ANC, noise removed - snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ON + second.snapshot.count shouldBe 1 + second.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ON } @Test @@ -101,10 +95,10 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 50), ) - coord.enqueue(AapCommand.SetAdaptiveAudioNoise(70), state) - val (_, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) - snapshot.count shouldBe 2 + second.snapshot.count shouldBe 2 } @Test @@ -114,27 +108,21 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled = false), ) - val (optimistic, _) = coord.enqueue(AapCommand.SetVolumeSwipe(true), state) + val result = coord.enqueue(emptyList(), AapCommand.SetVolumeSwipe(true), state) - optimistic.shouldNotBeNull() - optimistic.setting()!!.enabled shouldBe true + result.optimisticState.shouldNotBeNull() + result.optimisticState!!.setting()!!.enabled shouldBe true } - } - - // ── Flush ─────────────────────────────────────────────── - - @Nested - inner class FlushTests { @Test fun `flush empty queue returns empty list and zero snapshot`() { val coord = createCoordinator() - val (commands, snapshot) = coord.flush() + val result = coord.flush(emptyList()) - commands.shouldBeEmpty() - snapshot.count shouldBe 0 - snapshot.pendingAncMode.shouldBeNull() + result.commands.shouldBeEmpty() + result.snapshot.count shouldBe 0 + result.snapshot.pendingAncMode.shouldBeNull() } @Test @@ -144,14 +132,14 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), ) - coord.enqueue(AapCommand.SetToneVolume(80), state) - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val result = coord.flush(second.pendingCommands) - val (commands, _) = coord.flush() - - commands shouldHaveSize 2 - commands[0].shouldBeInstanceOf() - commands[1].shouldBeInstanceOf() + result.commands shouldHaveSize 2 + result.commands[0].shouldBeInstanceOf() + result.commands[1].shouldBeInstanceOf() + result.pendingCommands.shouldBeEmpty() } @Test @@ -161,34 +149,44 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), ) - coord.enqueue(AapCommand.SetToneVolume(80), state) - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state) - coord.enqueue(AapCommand.SetAllowOffOption(true), state) + val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state) + val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state) + val result = coord.flush(third.pendingCommands) - val (commands, _) = coord.flush() - - commands shouldHaveSize 3 - commands[0].shouldBeInstanceOf() - commands[1].shouldBeInstanceOf() - commands[2].shouldBeInstanceOf() + result.commands shouldHaveSize 3 + result.commands[0].shouldBeInstanceOf() + result.commands[1].shouldBeInstanceOf() + result.commands[2].shouldBeInstanceOf() } @Test - fun `flush clears queue`() { + fun `removeFromQueue returns updated snapshot`() { val coord = createCoordinator() - val state = stateWithSetting() + val state = stateWithSetting( + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), + ) - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - coord.flush() + val first = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetToneVolume(80), state) + val result = coord.removeFromQueue(second.pendingCommands, AapCommand.SetAncMode::class) - val (commands, snapshot) = coord.flush() - commands.shouldBeEmpty() - snapshot.count shouldBe 0 + result.snapshot.count shouldBe 1 + result.snapshot.pendingAncMode.shouldBeNull() + } + + @Test + fun `clear returns empty snapshot`() { + val coord = createCoordinator() + + val result = coord.clear() + + result.snapshot.count shouldBe 0 + result.snapshot.pendingAncMode.shouldBeNull() + result.pendingCommands.shouldBeEmpty() } } - // ── Optimistic Update ─────────────────────────────────── - @Nested inner class OptimisticUpdateTests { @@ -203,7 +201,7 @@ class AapSettingsCoordinatorTest : BaseTest() { @Test fun `returns null when setting not yet in state`() { val coord = createCoordinator() - val state = stateWithSetting() // No ToneVolume in state + val state = stateWithSetting() coord.optimisticUpdate(state, AapCommand.SetToneVolume(80)).shouldBeNull() } @@ -267,10 +265,8 @@ class AapSettingsCoordinatorTest : BaseTest() { } } - // ── Verification ──────────────────────────────────────── - @Nested - inner class VerificationTests { + inner class VerificationPredicateTests { @Test fun `verificationFor returns null for SetDeviceName`() { @@ -299,193 +295,5 @@ class AapSettingsCoordinatorTest : BaseTest() { check(matching) shouldBe true check(mismatched) shouldBe false } - - @Test - fun `confirmed when state matches after delay`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(1100L) - outcome.shouldBeInstanceOf() - } - - @Test - fun `resends on mismatch then confirms if retry succeeds`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - var sendCount = 0 - var currentLevel = 50 // Initially wrong - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = currentLevel)) - }, - sendRaw = { - sendCount++ - currentLevel = 80 // Simulate device accepting on retry - }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(1100L) // First check — mismatch, triggers resend - sendCount shouldBe 1 - advanceTimeBy(1100L) // Second check — matches now - outcome.shouldBeInstanceOf() - } - - @Test - fun `rejected after failed retry`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50)) // Always wrong - }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(2200L) // Both checks fail - outcome.shouldBeInstanceOf() - } - - @Test - fun `aborts when no pod in ear`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - var sendCount = 0 - - val state = AapPodState( - connectionState = AapPodState.ConnectionState.READY, - settings = mapOf( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), - AapSetting.EarDetection::class to AapSetting.EarDetection( - primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - ), - ), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { sendCount++ }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(2200L) - sendCount shouldBe 0 // No resend attempt - outcome.shouldBeNull() // No outcome — aborted silently - } - - @Test - fun `new startVerification cancels previous`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var firstOutcome: AapSettingsCoordinator.VerificationOutcome? = null - var secondOutcome: AapSettingsCoordinator.VerificationOutcome? = null - - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80), - AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = AapSetting.PressSpeed.Value.DEFAULT), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { firstOutcome = it }, - ) - - // Start second verification before first completes - coord.startVerification( - command = AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.DEFAULT), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { secondOutcome = it }, - ) - - advanceTimeBy(1100L) - firstOutcome.shouldBeNull() // Cancelled, never completed - secondOutcome.shouldBeInstanceOf() - } - } - - // ── Clear ─────────────────────────────────────────────── - - @Nested - inner class ClearTests { - - @Test - fun `clear empties queue and returns zero snapshot`() { - val coord = createCoordinator() - val state = stateWithSetting() - - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - val snapshot = coord.clear() - - snapshot.count shouldBe 0 - snapshot.pendingAncMode.shouldBeNull() - } - - @Test - fun `clear cancels verification`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80)) - }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - coord.clear() - advanceTimeBy(2000L) - outcome.shouldBeNull() // Cancelled, no outcome - } - } - - // ── Pending State ─────────────────────────────────────── - - @Nested - inner class PendingStateTests { - - @Test - fun `removeFromQueue returns updated snapshot`() { - val coord = createCoordinator() - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), - ) - - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - coord.enqueue(AapCommand.SetToneVolume(80), state) - - val snapshot = coord.removeFromQueue(AapCommand.SetAncMode::class) - - snapshot.count shouldBe 1 - snapshot.pendingAncMode.shouldBeNull() - } } }