fix(aap): Fix flush ANC debounce, HANDSHAKING→READY regression, and flush ordering

Track lastAncSentAt separately so non-ANC commands during flush don't break the ANC echo debounce window. Prioritize ANC for post-flush verification.

Move HANDSHAKING→READY transition to top of processMessage so decoded battery, stem press, and device info messages also trigger it.

Sort flush: AllowOffOption before AncMode before others, preventing device rejection when enabling OFF mode.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent e5821de4dd
commit 4ba59636c0
5 changed files with 501 additions and 21 deletions
@@ -38,8 +38,7 @@ internal class AapConnection(
private val device: BluetoothDevice,
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
private val timeSource: TimeSource,
private val psm: Int = 0x1001,
timeSource: TimeSource,
) {
private val engine = AapSessionEngine(profile, timeSource)
@@ -65,7 +64,7 @@ internal class AapConnection(
engine.start(scope)
try {
val sock = socketFactory.createSocket(device, psm)
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
socket = sock
log(TAG, INFO) { "Connected to ${device.address}" }
@@ -168,6 +167,7 @@ internal class AapConnection(
}
companion object {
private const val PSM = 0x1001
private val TAG = logTag("AAP", "Connection")
}
}
@@ -55,6 +55,8 @@ internal class AapSessionEngine(
private var ancDebounceJob: 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
/** Stored reference to the socket write callback — set on each [send] / flush call. */
@@ -82,6 +84,7 @@ internal class AapSessionEngine(
scope = null
lastSentCommand = null
lastSentAt = 0L
lastAncSentAt = 0L
activeSendRaw = null
handshakeResponseReceived = false
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
@@ -148,7 +151,9 @@ internal class AapSessionEngine(
private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) {
sendRaw(command)
lastSentCommand = command
lastSentAt = timeSource.currentTimeMillis()
val now = timeSource.currentTimeMillis()
lastSentAt = now
if (command is AapCommand.SetAncMode) lastAncSentAt = now
}
private fun onVerificationOutcome(outcome: AapSettingsCoordinator.VerificationOutcome) {
@@ -173,6 +178,16 @@ internal class AapSessionEngine(
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// 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
) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
// Issue #173 diagnostic dump
if (message.commandType == 0x001D) {
val segments = describeDeviceInfoSegments(message.payload)
@@ -241,8 +256,7 @@ internal class AapSessionEngine(
// ANC debounce
if (value is AapSetting.AncMode) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val recentAncSend =
lastSentCommand is AapCommand.SetAncMode && (timeSource.currentTimeMillis() - lastSentAt) <= 3000L
val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L
if (isFirstAncMode || recentAncSend) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
@@ -309,7 +323,10 @@ internal class AapSessionEngine(
break
}
}
commands.lastOrNull()?.let {
// 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,
@@ -322,22 +339,9 @@ internal class AapSessionEngine(
}
}
// HANDSHAKING → READY transition
if (!handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
return
}
// Track handshake response for non-setting messages too
if (!handshakeResponseReceived && message.commandType != 0x0009 && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
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"
@@ -73,7 +73,16 @@ internal class AapSettingsCoordinator(
commands = pendingCommands.values.toList()
pendingCommands.clear()
}
val sorted = commands.sortedBy { if (it is AapCommand.SetAncMode) 0 else 1 }
// 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 {
when (it) {
is AapCommand.SetAllowOffOption -> 0
is AapCommand.SetAncMode -> 1
else -> 2
}
}
return sorted to snapshot()
}