mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(aap): Tell a listening mode refusal apart from an unusable echo
Follow-up to the previous commit, which left the wrong half of this in place. Distrusting the report only while the request was outstanding meant that four seconds later the rejection cleared the pending mode, the bogus value came back, and the user was shown an error for a mode change that had actually worked. The two cases have different signatures, both readable from state the engine already holds: - A refusal echoes the mode the device is staying in, quickly. Captured Off refusals answer in 25-267ms with the previous mode. - The Pro 3 misreport answers with a third mode, neither the one requested nor the one it was in, at normal change latency (815-1010ms). So an echo that is neither the requested nor the previous mode is treated as an unusable report rather than a refusal: no re-send of a write that already took effect, no rejection, no error, and the requested mode is recorded as current. Refusals still work, which is what the Off rejection message and the Allow Off learning depend on. This is deliberately engine-local. Seeding the cycle mask and Allow Off belief from the device profile into the session would have encoded the rule directly, but it inverts the current engine-to-profile data flow and creates a belief that no device report can ever correct, since AirPods never report 0x1A or 0x34. librepods keeps the same knowledge in its service layer and preferences, not in its protocol manager. The fault is per-session rather than per-request: across four sessions today the pods either misreported every Adaptive write or none of them. The heuristic is covered by unit tests but has not yet been observed handling a live bad session.
This commit is contained in:
+59
-2
@@ -8,6 +8,12 @@ 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,
|
||||
)
|
||||
|
||||
internal data class OutboundRuntimeState(
|
||||
@@ -86,7 +92,13 @@ internal class AapOutboundController(
|
||||
return OutboundDecision(
|
||||
podState = updatedPodState,
|
||||
runtimeState = updatedRuntimeState.copy(
|
||||
verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) }
|
||||
verification = verificationCheck?.let {
|
||||
VerificationState(
|
||||
command = command,
|
||||
attempt = 0,
|
||||
previousAncMode = podState.setting<AapSetting.AncMode>()?.current,
|
||||
)
|
||||
}
|
||||
?: updatedRuntimeState.verification,
|
||||
),
|
||||
commandsToSend = listOf(command),
|
||||
@@ -118,7 +130,11 @@ internal class AapOutboundController(
|
||||
runtimeState = runtimeState.copy(
|
||||
pendingCommands = result.pendingCommands,
|
||||
verification = if (verificationCheck != null) {
|
||||
VerificationState(command = checkNotNull(toVerify), attempt = 0)
|
||||
VerificationState(
|
||||
command = checkNotNull(toVerify),
|
||||
attempt = 0,
|
||||
previousAncMode = podState.setting<AapSetting.AncMode>()?.current,
|
||||
)
|
||||
} else {
|
||||
runtimeState.verification
|
||||
},
|
||||
@@ -179,6 +195,8 @@ 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
|
||||
@@ -208,6 +226,45 @@ 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 does so
|
||||
* 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.
|
||||
*
|
||||
* Deliberately engine-local: it uses only the requested mode, the previous mode and the echo.
|
||||
* Which modes a device permits is app-level knowledge and stays out of the session engine.
|
||||
*/
|
||||
private fun unusableAncReport(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
verification: VerificationState,
|
||||
): OutboundDecision? {
|
||||
val command = verification.command as? AapCommand.SetAncMode ?: return null
|
||||
val previous = verification.previousAncMode ?: return null
|
||||
val ancMode = podState.setting<AapSetting.AncMode>() ?: return null
|
||||
val reported = ancMode.current
|
||||
if (reported == command.mode || reported == previous) 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=$reported, was=$previous), " +
|
||||
"not a refusal: keeping ${command.mode}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearPendingForCommand(
|
||||
podState: AapPodState,
|
||||
command: AapCommand,
|
||||
|
||||
@@ -724,6 +724,108 @@ 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.
|
||||
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 `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()) {
|
||||
|
||||
Reference in New Issue
Block a user