mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
refactor(aap): Split the echo classifier out of this change
Everything that reinterprets what the device reported moves to its own branch (anc-echo-classifier), leaving only changes that stand on their own. The classifier addresses a fault that has never been observed being handled: it reproduced on two of four sessions and none since it was written. It also adds a failure mode that did not exist before, where a report misattributed to our own write makes CAPod show a mode the device is not in. That is a poor trade to carry into main on the strength of tests alone, so it waits until it can be seen working against a live fault. What remains does not depend on the misreport: - the verification deadline was 1000ms while the device answers in 833-1008ms, so a healthy reply could land just after the timer and trigger a bogus divergence plus a redundant re-send; this was captured live - a listening mode request the device did not confirm produced no feedback at all for any mode except Off, which was a gap in the event plumbing rather than a timing artifact - a mode outside the device's listening mode cycle was rendered as an ordinary selectable button whenever it happened to be the current mode - an Off report arriving while a different mode was requested could teach the Allow Off inference, persisting "Off is permitted" into the device profile Also drops effectiveAncMode, which only had an effect while the classifier was present.
This commit is contained in:
@@ -49,7 +49,6 @@ import eu.darken.capod.main.ui.devicesettings.components.SettingsCompoundHeader
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
@@ -97,7 +96,7 @@ internal fun NoiseControlCard(
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) {
|
||||
NoiseControlCurrentModeControl(
|
||||
currentMode = device.effectiveAncMode ?: ancMode.current,
|
||||
currentMode = ancMode.current,
|
||||
pendingMode = device.pendingAncMode,
|
||||
supportedModes = device.visibleAncModes,
|
||||
onModeSelected = onAncModeChange,
|
||||
@@ -174,7 +173,7 @@ internal fun NoiseControlCard(
|
||||
level = adaptiveNoise.level,
|
||||
onLevelChange = onAdaptiveAudioNoiseChange,
|
||||
enabled = enabled,
|
||||
isAdaptiveMode = device.effectiveAncMode == AapSetting.AncMode.Value.ADAPTIVE
|
||||
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE
|
||||
|| device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
||||
import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
|
||||
@@ -287,7 +286,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = device.effectiveAncMode ?: ancMode.current,
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = device.visibleAncModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
|
||||
@@ -44,7 +44,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||
@@ -335,7 +334,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = device.effectiveAncMode ?: ancMode.current,
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = device.visibleAncModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
|
||||
@@ -3,7 +3,6 @@ package eu.darken.capod.main.ui.tile
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
@@ -37,7 +36,7 @@ object AncTileStateMapper {
|
||||
if (visible.isEmpty()) return AncTileState.Connecting
|
||||
|
||||
return AncTileState.Active(
|
||||
current = device.effectiveAncMode ?: ancMode.current,
|
||||
current = ancMode.current,
|
||||
pending = device.pendingAncMode,
|
||||
visible = visible,
|
||||
deviceLabel = device.label,
|
||||
|
||||
@@ -5,7 +5,6 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
@@ -92,7 +91,7 @@ object AncWidgetRenderStateMapper {
|
||||
primaryText = context.getString(R.string.anc_widget_aap_connecting_label),
|
||||
)
|
||||
|
||||
val currentMode = device.effectiveAncMode ?: ancMode.current
|
||||
val currentMode = ancMode.current
|
||||
val pendingMode = device.pendingAncMode
|
||||
|
||||
val filteredModes = device.visibleAncModes
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.effectiveAncMode
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
@@ -54,7 +53,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
ancMode = effectiveAncMode,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
)
|
||||
|
||||
@@ -44,30 +44,6 @@ fun visibleAncModes(
|
||||
isAncModePermitted(mode, cycleMask, allowOffEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode to display. Normally whatever the device reported.
|
||||
*
|
||||
* AirPods Pro 3 have been observed answering a listening mode write with a mode they cannot
|
||||
* actually be in - reporting OFF (wire 0x01) while audibly switching to Adaptive, on a device
|
||||
* where OFF is outside the cycle and Allow Off is disabled. Adopting that verbatim shows the
|
||||
* wrong mode as selected. While our own request is still outstanding, a reported mode that the
|
||||
* device should not be able to reach is treated as noise and the requested mode is shown instead.
|
||||
*
|
||||
* Once the request is resolved (confirmed or rejected) [pendingMode] is null and the reported
|
||||
* value is shown again - out-of-cycle it will simply not match any selectable entry.
|
||||
*/
|
||||
fun effectiveAncMode(
|
||||
reportedMode: AapSetting.AncMode.Value,
|
||||
pendingMode: AapSetting.AncMode.Value?,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): AapSetting.AncMode.Value = when {
|
||||
pendingMode == null -> reportedMode
|
||||
reportedMode == pendingMode -> reportedMode
|
||||
isAncModePermitted(reportedMode, cycleMask, allowOffEnabled) -> reportedMode
|
||||
else -> pendingMode
|
||||
}
|
||||
|
||||
val PodDevice.resolvedAncCycleMask: Int?
|
||||
get() = resolvedAncCycleMask(
|
||||
hasListeningModeCycle = model.features.hasListeningModeCycle,
|
||||
@@ -88,15 +64,3 @@ val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
/** Display-facing listening mode. See [effectiveAncMode]. */
|
||||
val PodDevice.effectiveAncMode: AapSetting.AncMode.Value?
|
||||
get() {
|
||||
val ancMode = ancMode ?: return null
|
||||
return effectiveAncMode(
|
||||
reportedMode = ancMode.current,
|
||||
pendingMode = pendingAncMode,
|
||||
cycleMask = resolvedAncCycleMask,
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-142
@@ -8,24 +8,6 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
internal data class VerificationState(
|
||||
val command: AapCommand,
|
||||
val attempt: Int = 0,
|
||||
/**
|
||||
* The listening mode the device was in when the write went out. Used to tell a refusal (the
|
||||
* device echoes the mode it is staying in) apart from an unusable report (a third mode). Null
|
||||
* for non-ANC commands.
|
||||
*/
|
||||
val previousAncMode: AapSetting.AncMode.Value? = null,
|
||||
/** Monotonic-ish timestamp of the write, used to measure how long the device took to answer. */
|
||||
val sentAtMs: Long = 0L,
|
||||
/** The first listening mode report seen since the write, with how long it took to arrive. */
|
||||
val observedAncEcho: AncEcho? = null,
|
||||
/** True once another listening mode write was issued while this one was still outstanding. */
|
||||
val superseded: Boolean = false,
|
||||
)
|
||||
|
||||
/** A listening mode report attributed to an outstanding write. */
|
||||
internal data class AncEcho(
|
||||
val mode: AapSetting.AncMode.Value,
|
||||
val latencyMs: Long,
|
||||
)
|
||||
|
||||
internal data class OutboundRuntimeState(
|
||||
@@ -43,7 +25,7 @@ internal data class OutboundDecision(
|
||||
)
|
||||
|
||||
internal class AapOutboundController(
|
||||
private val timeSource: TimeSource,
|
||||
timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private val coordinator = AapSettingsCoordinator(timeSource)
|
||||
@@ -63,14 +45,6 @@ internal class AapOutboundController(
|
||||
* still costs two full deadlines before the user is told about it.
|
||||
*/
|
||||
const val VERIFICATION_TIMEOUT_MS = 2000L
|
||||
|
||||
/**
|
||||
* Below this, a listening mode report is a refusal rather than the result of a change.
|
||||
*
|
||||
* Captured refusals answer in 25-267ms; a real mode change answers in 815-1010ms. Only an
|
||||
* answer slow enough to be a change is eligible to be read as an unusable report.
|
||||
*/
|
||||
const val ANC_CHANGE_LATENCY_MIN_MS = 500L
|
||||
}
|
||||
|
||||
fun onCommandRequested(
|
||||
@@ -112,18 +86,7 @@ internal class AapOutboundController(
|
||||
return OutboundDecision(
|
||||
podState = updatedPodState,
|
||||
runtimeState = updatedRuntimeState.copy(
|
||||
verification = verificationCheck?.let {
|
||||
VerificationState(
|
||||
command = command,
|
||||
attempt = 0,
|
||||
previousAncMode = podState.setting<AapSetting.AncMode>()?.current,
|
||||
sentAtMs = timeSource.elapsedRealtime(),
|
||||
// A second listening mode write while one is outstanding makes the echoes
|
||||
// ambiguous: we can no longer say which write any given report answers.
|
||||
superseded = command is AapCommand.SetAncMode &&
|
||||
updatedRuntimeState.verification?.command is AapCommand.SetAncMode,
|
||||
)
|
||||
}
|
||||
verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) }
|
||||
?: updatedRuntimeState.verification,
|
||||
),
|
||||
commandsToSend = listOf(command),
|
||||
@@ -155,13 +118,7 @@ internal class AapOutboundController(
|
||||
runtimeState = runtimeState.copy(
|
||||
pendingCommands = result.pendingCommands,
|
||||
verification = if (verificationCheck != null) {
|
||||
VerificationState(
|
||||
command = checkNotNull(toVerify),
|
||||
attempt = 0,
|
||||
previousAncMode = podState.setting<AapSetting.AncMode>()?.current,
|
||||
sentAtMs = timeSource.elapsedRealtime(),
|
||||
superseded = runtimeState.verification?.command is AapCommand.SetAncMode,
|
||||
)
|
||||
VerificationState(command = checkNotNull(toVerify), attempt = 0)
|
||||
} else {
|
||||
runtimeState.verification
|
||||
},
|
||||
@@ -176,43 +133,6 @@ internal class AapOutboundController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute a listening mode report to the outstanding write and remember how long it took.
|
||||
*
|
||||
* Only the first report after the write is kept: that is the one the device sent in answer.
|
||||
* Classification later uses this recorded frame rather than whatever happens to be current at
|
||||
* the deadline, so an unrelated concurrent report cannot be mistaken for our echo.
|
||||
*/
|
||||
fun onAncReportObserved(
|
||||
runtimeState: OutboundRuntimeState,
|
||||
mode: AapSetting.AncMode.Value,
|
||||
nowMs: Long,
|
||||
): OutboundRuntimeState {
|
||||
val verification = runtimeState.verification ?: return runtimeState
|
||||
if (verification.command !is AapCommand.SetAncMode) return runtimeState
|
||||
if (verification.observedAncEcho != null) return runtimeState
|
||||
return runtimeState.copy(
|
||||
verification = verification.copy(
|
||||
observedAncEcho = AncEcho(mode = mode, latencyMs = nowMs - verification.sentAtMs),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an outstanding listening mode write ambiguous because the user changed the mode on the
|
||||
* device itself. Any report arriving now could answer either, and the two cannot be told apart.
|
||||
*
|
||||
* This only covers changes CAPod can see. A switch made from iOS or another paired phone is
|
||||
* invisible here, which is why a misattributed echo is only ever allowed to affect the current
|
||||
* reading and is never learned from.
|
||||
*/
|
||||
fun onExternalAncChange(runtimeState: OutboundRuntimeState): OutboundRuntimeState {
|
||||
val verification = runtimeState.verification ?: return runtimeState
|
||||
if (verification.command !is AapCommand.SetAncMode) return runtimeState
|
||||
if (verification.superseded) return runtimeState
|
||||
return runtimeState.copy(verification = verification.copy(superseded = true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the outstanding verification against freshly applied device state, so a confirmation
|
||||
* is honoured the moment it arrives instead of waiting out [VERIFICATION_TIMEOUT_MS] and racing
|
||||
@@ -259,8 +179,6 @@ internal class AapOutboundController(
|
||||
)
|
||||
}
|
||||
|
||||
unusableAncReport(podState, runtimeState, verification)?.let { return it }
|
||||
|
||||
val ear = podState.setting<AapSetting.EarDetection>()
|
||||
if (ear != null && !ear.isEitherPodInEar) {
|
||||
// Drop the pending mode too: nothing is going to confirm it now, and leaving it set
|
||||
@@ -275,15 +193,7 @@ internal class AapOutboundController(
|
||||
if (verification.attempt == 0) {
|
||||
return OutboundDecision(
|
||||
podState = podState,
|
||||
runtimeState = runtimeState.copy(
|
||||
// A re-send is a fresh question: the previous attempt's echo and send time
|
||||
// must not be carried over, or the retry gets judged on stale evidence.
|
||||
verification = verification.copy(
|
||||
attempt = 1,
|
||||
sentAtMs = timeSource.elapsedRealtime(),
|
||||
observedAncEcho = null,
|
||||
),
|
||||
),
|
||||
runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)),
|
||||
commandsToSend = listOf(verification.command),
|
||||
timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)),
|
||||
logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"),
|
||||
@@ -298,54 +208,6 @@ internal class AapOutboundController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish a refusal from a report we cannot act on.
|
||||
*
|
||||
* A device that refuses a listening mode write echoes the mode it is staying in, and answers
|
||||
* quickly (25-267ms in captures). AirPods Pro 3 have instead been seen answering an ADAPTIVE
|
||||
* write with OFF at normal change latency (815-1010ms) while audibly switching to Adaptive:
|
||||
* a third mode, neither the one requested nor the one it was in.
|
||||
*
|
||||
* Retrying that write is pointless (it already took effect) and reporting it as rejected is
|
||||
* wrong. Treat the echo as noise, record the mode we asked for as current, and stop verifying.
|
||||
* The raw frame is still logged upstream; nothing is suppressed at the protocol layer.
|
||||
*
|
||||
* Every condition below exists to keep a wrong conclusion out of the session:
|
||||
* - the recorded echo is used, never whatever is current at the deadline, so an unrelated
|
||||
* concurrent report cannot be mistaken for the answer to our write
|
||||
* - a write that was superseded by another listening mode write is never classified, because
|
||||
* its echoes can no longer be attributed
|
||||
* - an answer fast enough to be a refusal is never read as a change
|
||||
*
|
||||
* Deliberately engine-local: it uses only the requested mode, the previous mode, and the echo
|
||||
* we recorded. Which modes a device permits is app-level knowledge and stays out of the engine.
|
||||
*/
|
||||
private fun unusableAncReport(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
verification: VerificationState,
|
||||
): OutboundDecision? {
|
||||
val command = verification.command as? AapCommand.SetAncMode ?: return null
|
||||
if (verification.superseded) return null
|
||||
val previous = verification.previousAncMode ?: return null
|
||||
val echo = verification.observedAncEcho ?: return null
|
||||
if (echo.mode == command.mode || echo.mode == previous) return null
|
||||
if (echo.latencyMs < ANC_CHANGE_LATENCY_MIN_MS) return null
|
||||
val ancMode = podState.setting<AapSetting.AncMode>() ?: return null
|
||||
|
||||
return OutboundDecision(
|
||||
podState = clearPendingForCommand(
|
||||
podState.withSetting(AapSetting.AncMode::class, ancMode.copy(current = command.mode)),
|
||||
command,
|
||||
),
|
||||
runtimeState = runtimeState.copy(verification = null),
|
||||
logs = listOf(
|
||||
"Unusable ANC echo for ${command.mode} (reported=${echo.mode}, was=$previous, " +
|
||||
"after ${echo.latencyMs}ms), not a refusal: keeping ${command.mode}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearPendingForCommand(
|
||||
podState: AapPodState,
|
||||
command: AapCommand,
|
||||
|
||||
@@ -231,9 +231,6 @@ internal class AapSessionEngine(
|
||||
private fun handleInboundUpdate(update: AapInboundUpdate) {
|
||||
when (update) {
|
||||
is AapInboundUpdate.StemPress -> {
|
||||
runtimeState = runtimeState.copy(
|
||||
outbound = outboundController.onExternalAncChange(runtimeState.outbound),
|
||||
)
|
||||
_stemPressEvents.tryEmit(update.event)
|
||||
log(TAG) { "Stem press: ${update.event.pressType} ${update.event.bud}" }
|
||||
}
|
||||
@@ -289,13 +286,6 @@ internal class AapSessionEngine(
|
||||
|
||||
private fun handleSettingUpdate(key: KClass<out AapSetting>, value: AapSetting) {
|
||||
if (value is AapSetting.AncMode) {
|
||||
runtimeState = runtimeState.copy(
|
||||
outbound = outboundController.onAncReportObserved(
|
||||
runtimeState = runtimeState.outbound,
|
||||
mode = value.current,
|
||||
nowMs = timeSource.elapsedRealtime(),
|
||||
),
|
||||
)
|
||||
val decision = ancController.onAncSetting(
|
||||
podState = _state.value,
|
||||
runtimeState = runtimeState.anc,
|
||||
|
||||
@@ -54,49 +54,6 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
)
|
||||
}
|
||||
|
||||
// -- effectiveAncMode: distrusting an impossible report while a request is in flight --
|
||||
|
||||
@Test
|
||||
fun `impossible reported mode is ignored while our request is pending`() {
|
||||
effectiveAncMode(
|
||||
reportedMode = AapSetting.AncMode.Value.OFF,
|
||||
pendingMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permitted reported mode is adopted even while pending`() {
|
||||
// A genuine refusal echoes the mode the device is actually in; that must win.
|
||||
effectiveAncMode(
|
||||
reportedMode = AapSetting.AncMode.Value.ON,
|
||||
pendingMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldBe AapSetting.AncMode.Value.ON
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reported mode is adopted verbatim when nothing is pending`() {
|
||||
effectiveAncMode(
|
||||
reportedMode = AapSetting.AncMode.Value.OFF,
|
||||
pendingMode = null,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldBe AapSetting.AncMode.Value.OFF
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OFF report is adopted while pending when OFF is actually allowed`() {
|
||||
effectiveAncMode(
|
||||
reportedMode = AapSetting.AncMode.Value.OFF,
|
||||
pendingMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = true,
|
||||
) shouldBe AapSetting.AncMode.Value.OFF
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null cycle mask shows all supported modes`() {
|
||||
visibleAncModes(
|
||||
|
||||
+50
-323
@@ -31,22 +31,9 @@ import kotlin.reflect.KClass
|
||||
|
||||
class AapSessionEngineTest : BaseTest() {
|
||||
|
||||
/**
|
||||
* Wall clock the engine reads. Advanceable because classification of a listening mode echo
|
||||
* depends on how long the device took to answer, so tests must be able to simulate a reply
|
||||
* that is slow enough to be a real mode change rather than a refusal.
|
||||
*/
|
||||
private var fakeNowMs = 1000L
|
||||
|
||||
private val timeSource = mockk<TimeSource> {
|
||||
every { now() } answers { Instant.ofEpochMilli(fakeNowMs) }
|
||||
every { currentTimeMillis() } answers { fakeNowMs }
|
||||
every { elapsedRealtime() } answers { fakeNowMs }
|
||||
}
|
||||
|
||||
/** Simulate the device taking a realistic amount of time to answer a mode change. */
|
||||
private fun elapseChangeLatency() {
|
||||
fakeNowMs += AapOutboundController.ANC_CHANGE_LATENCY_MIN_MS + 400L
|
||||
every { now() } returns Instant.ofEpochMilli(1000L)
|
||||
every { currentTimeMillis() } returns 1000L
|
||||
}
|
||||
|
||||
private fun dummyMessage(commandType: Int = 0x0009): AapMessage {
|
||||
@@ -737,314 +724,6 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `third-mode echo is treated as an unusable report, not a refusal`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<AapCommand>()
|
||||
val collectJob = launch { engine.settingRejected.collect { rejected += it } }
|
||||
|
||||
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<AapCommand>()
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
|
||||
|
||||
// AirPods Pro 3 answering an ADAPTIVE write with OFF: neither the requested mode
|
||||
// nor the one it was in. The write did take effect, so this must not be retried
|
||||
// or reported as a rejection.
|
||||
elapseChangeLatency()
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
|
||||
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
|
||||
rejected shouldBe emptyList()
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.ADAPTIVE
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fast third-mode echo is a refusal, not an unusable report`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// A refusal that settles in some third mode still answers at refusal speed. Without
|
||||
// the latency check this was misread as a misreport and poisoned the session.
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, 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())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { }
|
||||
fakeNowMs += 40L // refusal speed
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
|
||||
// Must NOT have rewritten state to OFF, and must NOT have learned a mapping.
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a superseded ANC write is never classified as an unusable report`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// Two writes in flight make the echoes unattributable: a delayed answer to the
|
||||
// first looks like a third mode to the second.
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, 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())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.TRANSPARENCY)) { }
|
||||
|
||||
// Delayed answer to the FIRST write arrives while the second is outstanding.
|
||||
elapseChangeLatency()
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
|
||||
// Without supersession the classifier would call this ADAPTIVE report the answer to
|
||||
// the TRANSPARENCY write and force TRANSPARENCY into state. State must instead stay
|
||||
// on what the device actually reported.
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fast refusal on the retry is not inflated by the first attempt's timestamp`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// Attempt 0 draws no answer at all. Without restamping on re-send, a quick refusal
|
||||
// to attempt 1 measures from the original write and sails past the latency gate.
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, 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())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { }
|
||||
|
||||
// Silence through the first deadline, so a re-send goes out.
|
||||
fakeNowMs += AapOutboundController.VERIFICATION_TIMEOUT_MS
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L)
|
||||
|
||||
// Refusal speed, relative to the re-send.
|
||||
fakeNowMs += 40L
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L)
|
||||
|
||||
// Must be read as a refusal: state stays where the device says it is.
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stem press makes an outstanding ANC write ambiguous`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
var nextStemPress: StemPressEvent? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
every { decodeStemPress(any()) } answers { nextStemPress }
|
||||
}
|
||||
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())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
|
||||
// The user changes the mode on the pods themselves mid-request.
|
||||
nextSetting = null
|
||||
nextStemPress = StemPressEvent(
|
||||
pressType = StemPressEvent.PressType.SINGLE,
|
||||
bud = StemPressEvent.Bud.LEFT,
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
nextStemPress = null
|
||||
|
||||
elapseChangeLatency()
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
|
||||
// Must NOT have been claimed as our own write's result.
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `echo of the previous mode is still treated as a refusal`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<AapCommand>()
|
||||
val collectJob = launch { engine.settingRejected.collect { rejected += it } }
|
||||
|
||||
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<AapCommand>()
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
|
||||
|
||||
// A real refusal echoes the mode the device is staying in.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
|
||||
sentCommands.size shouldBe 2
|
||||
rejected shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF))
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe
|
||||
AapSetting.AncMode.Value.ON
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
@@ -1086,6 +765,54 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited OFF after a contradicted one still infers AllowOffOption true`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, 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())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// Let the request finish failing, so nothing of ours is outstanding any more.
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
|
||||
// Now a genuine switch into OFF (stem press / another phone) must still train it.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated setting report does not prematurely confirm a non-ANC command`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
|
||||
Reference in New Issue
Block a user