diff --git a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt index 757dfce5..50fdbbdb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt @@ -28,19 +28,3 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) { AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome } -fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { - AapSetting.AncMode.Value.OFF -> 0x01 - AapSetting.AncMode.Value.ON -> 0x02 - AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 - AapSetting.AncMode.Value.ADAPTIVE -> 0x08 -} - -fun visibleAncModes( - supportedModes: List, - currentMode: AapSetting.AncMode.Value, - cycleMask: Int?, - allowOffEnabled: Boolean, -): List = supportedModes.filter { mode -> - val inCycle = cycleMask?.let { (it and mode.cycleBit()) != 0 } ?: mode != AapSetting.AncMode.Value.OFF - inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode -} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 214e41e6..1e1f9e49 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -99,6 +99,8 @@ import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDia import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.monitor.core.lastSeenFormatted +import eu.darken.capod.monitor.core.resolvedAncCycleMask +import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting @@ -516,9 +518,7 @@ fun DeviceSettingsScreen( val ancMode = device.ancMode val adaptiveNoise = device.adaptiveAudioNoise if (features.hasAncControl && ancMode != null) { - val cycleMask = if (features.hasListeningModeCycle) { - (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask - } else null + val cycleMask = device.resolvedAncCycleMask val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null val cycleSubtitle = if (cycleSummary != null) { buildString { @@ -543,7 +543,7 @@ fun DeviceSettingsScreen( NoiseControlCurrentModeControl( currentMode = ancMode.current, pendingMode = device.pendingAncMode, - supportedModes = ancMode.supported, + supportedModes = device.visibleAncModes, onModeSelected = onAncModeChange, enabled = enabled, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 58a5f0d7..eb711bb0 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -43,7 +43,7 @@ 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.main.ui.components.visibleAncModes +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 import eu.darken.capod.main.ui.overview.cards.components.DebugSection @@ -237,15 +237,9 @@ fun DualPodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(8.dp)) - val visibleModes = visibleAncModes( - supportedModes = ancMode.supported, - currentMode = ancMode.current, - cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, - allowOffEnabled = device.allowOffOption?.enabled == true, - ) AncModeSelector( currentMode = ancMode.current, - supportedModes = visibleModes, + supportedModes = device.visibleAncModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index e910e574..6cc262ab 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -43,7 +43,7 @@ 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.main.ui.components.visibleAncModes +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.DebugSection import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge @@ -260,15 +260,9 @@ fun SinglePodsCard( val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { Spacer(modifier = Modifier.height(12.dp)) - val visibleModes = visibleAncModes( - supportedModes = ancMode.supported, - currentMode = ancMode.current, - cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E, - allowOffEnabled = device.allowOffOption?.enabled == true, - ) AncModeSelector( currentMode = ancMode.current, - supportedModes = visibleModes, + supportedModes = device.visibleAncModes, onModeSelected = { onAncModeChange?.invoke(it) }, pendingMode = device.pendingAncMode, ) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt new file mode 100644 index 00000000..5d27eb14 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceAncMode.kt @@ -0,0 +1,50 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +private fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) { + AapSetting.AncMode.Value.OFF -> 0x01 + AapSetting.AncMode.Value.ON -> 0x02 + AapSetting.AncMode.Value.TRANSPARENCY -> 0x04 + AapSetting.AncMode.Value.ADAPTIVE -> 0x08 +} + +fun resolvedAncCycleMask( + hasListeningModeCycle: Boolean, + reportedCycleMask: Int?, +): Int? = if (hasListeningModeCycle) { + reportedCycleMask ?: 0x0E +} else { + null +} + +fun visibleAncModes( + supportedModes: List, + currentMode: AapSetting.AncMode.Value, + cycleMask: Int?, + allowOffEnabled: Boolean, +): List = supportedModes.filter { mode -> + val inCycle = if (cycleMask != null) { + (cycleMask and mode.cycleBit()) != 0 + } else { + true + } + inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode +} + +val PodDevice.resolvedAncCycleMask: Int? + get() = resolvedAncCycleMask( + hasListeningModeCycle = model.features.hasListeningModeCycle, + reportedCycleMask = listeningModeCycle?.modeMask, + ) + +val PodDevice.visibleAncModes: List + get() { + val ancMode = ancMode ?: return emptyList() + return visibleAncModes( + supportedModes = ancMode.supported, + currentMode = ancMode.current, + cycleMask = resolvedAncCycleMask, + allowOffEnabled = allowOffOption?.enabled == true, + ) + } 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 dd1ca1b7..7c1a6123 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 @@ -54,11 +54,13 @@ internal class AapSessionEngine( 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 @@ -81,6 +83,7 @@ internal class AapSessionEngine( fun reset() { ancDebounceJob?.cancel() ancDebounceJob = null + cancelAllowOffOptionInference() coordinator.clear() hidTracker.flush() hidTracker.reset() @@ -88,6 +91,7 @@ internal class AapSessionEngine( lastSentCommand = null lastSentAt = 0L lastAncSentAt = 0L + latestObservedAncMode = null activeSendRaw = null handshakeResponseReceived = false _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) @@ -163,6 +167,7 @@ internal class AapSessionEngine( 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( @@ -273,20 +278,20 @@ internal class AapSessionEngine( // 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]" } - applyInferences(value) } 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]" } - applyInferences(value) } } return @@ -304,7 +309,7 @@ internal class AapSessionEngine( } _state.value = newState log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } - applyInferences(value) + if (value is AapSetting.EarDetection) syncAllowOffOptionInference() // Flush queued commands when pod goes in ear if (value is AapSetting.EarDetection && value.isEitherPodInEar) { @@ -422,20 +427,42 @@ internal class AapSessionEngine( // ── Inference ─────────────────────────────────────────── - private fun applyInferences(trigger: AapSetting) { - val inferred = mutableListOf, AapSetting>>() + private fun syncAllowOffOptionInference() { + cancelAllowOffOptionInference() - if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) { - val current = _state.value.setting() - if (current == null || !current.enabled) { - inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true) + 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 } + } - for ((key, value) in inferred) { - _state.value = _state.value.withSetting(key, value) - log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" } - } + private fun cancelAllowOffOptionInference() { + allowOffInferenceJob?.cancel() + allowOffInferenceJob = null } companion object { diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt new file mode 100644 index 00000000..3913f556 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceAncModeTest.kt @@ -0,0 +1,95 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class PodDeviceAncModeTest : BaseTest() { + + private val allModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + + @Test + fun `cycle mask hides OFF when OFF is not allowed`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = 0x0E, + allowOffEnabled = false, + ) shouldContainExactly listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ) + } + + @Test + fun `allow off keeps OFF visible even when cycle mask excludes it`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = 0x0E, + allowOffEnabled = true, + ) shouldContainExactly allModes + } + + @Test + fun `current OFF stays visible even when OFF is otherwise hidden`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.OFF, + cycleMask = 0x0E, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `null cycle mask shows all supported modes`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = null, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `cycle mask with OFF bit set includes OFF`() { + visibleAncModes( + supportedModes = allModes, + currentMode = AapSetting.AncMode.Value.ON, + cycleMask = 0x0F, + allowOffEnabled = false, + ) shouldContainExactly allModes + } + + @Test + fun `models with listening mode cycle fall back to default mask when setting is absent`() { + resolvedAncCycleMask( + hasListeningModeCycle = true, + reportedCycleMask = null, + ) shouldBe 0x0E + } + + @Test + fun `models without listening mode cycle keep cycle mask null`() { + resolvedAncCycleMask( + hasListeningModeCycle = false, + reportedCycleMask = null, + ) shouldBe null + } + + @Test + fun `reported cycle mask wins over fallback`() { + resolvedAncCycleMask( + hasListeningModeCycle = true, + reportedCycleMask = 0x0A, + ) shouldBe 0x0A + } +} 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 74a22c95..f9bd0e05 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 @@ -40,6 +40,10 @@ class AapSessionEngineTest : BaseTest() { return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0)) } + @Suppress("UNCHECKED_CAST") + private fun settingPair(setting: AapSetting): Pair, AapSetting> = + setting::class as KClass to setting + /** Build a profile mock with all decode methods stubbed. Does NOT mock encodeCommand (sealed class). */ private fun mockProfile(block: AapDeviceProfile.() -> Unit = {}): AapDeviceProfile = mockk { every { decodeStemPress(any()) } returns null @@ -356,26 +360,159 @@ class AapSessionEngineTest : BaseTest() { inner class InferenceTests { @Test - fun `AncMode OFF infers AllowOffOption true`() { + fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() = 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 { decodeStemPress(any()) } returns null - every { decodeBattery(any()) } returns null - every { decodePrivateKeyResponse(any()) } returns null - every { decodeDeviceInfo(any()) } returns null - every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode( - current = AapSetting.AncMode.Value.OFF, - supported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON), - )) + every { decodeSetting(any()) } answers { nextSetting } } val engine = AapSessionEngine(profile, timeSource) - val scope = TestScope(UnconfinedTestDispatcher()) - engine.start(scope) + engine.start(this as TestScope) engine.onHandshakeSent() - // First ANC mode = no debounce, applied immediately + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + engine.state.value.setting().shouldBeNull() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + )) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + } + + @Test + fun `stable in-ear OFF infers AllowOffOption true after delay`() = 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.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.OFF, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + engine.state.value.setting().shouldBeNull() + + advanceTimeBy(1600L) + engine.state.value.setting()?.enabled shouldBe true + } + + @Test + fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() = 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.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.OFF, + supported = supportedModes, + )) engine.processMessage(dummyMessage()) - engine.state.value.setting()?.enabled shouldBe true + advanceTimeBy(500L) + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + advanceTimeBy(1600L) + engine.state.value.setting().shouldBeNull() + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + } + + @Test + fun `rejected OFF command infers AllowOffOption false`() = 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.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true)) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it } + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + advanceTimeBy(2100L) + engine.state.value.setting()?.enabled shouldBe false + sentCommands shouldBe listOf( + AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), + AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), + ) } }