Compare commits

..
Author SHA1 Message Date
d4rken-org-releaser[bot] 3ddd5f3cfd Release: 5.2.1-rc0 2026-07-08 11:21:18 +00:00
Matthias Urhahn 31f4d4aafa fix(battery): Stop remaining-time jumping up when toggling ANC
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.

Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.

The existing spec ceiling and display clamp still backstop the borrowed rate.
2026-07-07 22:23:01 +02:00
Matthias Urhahn e06dc933b8 chore(logging): Log BLE scan reception nanos in scan summaries 2026-07-06 12:42:10 +02:00
Matthias Urhahn 1418be999d feat(reaction): Time-cap auto-pause debounce on slow BLE scanners 2026-07-06 12:42:10 +02:00
7 changed files with 686 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
5.2.0-rc0 50200000
5.2.1-rc0 50201000
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
.sortedBy { it.key }
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
.ifEmpty { "-" }
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
}
@JvmName("logBleScanResultCollectionSummary")
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
.sorted()
.joinToString(separator = ",")
.ifEmpty { "-" }
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
}
@JvmName("logFrameworkScanResultCollectionSummary")
@@ -28,6 +28,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* Learns each device's battery drain rate from observed levels over time and turns it into a
@@ -569,9 +570,58 @@ class BatteryEstimator @Inject constructor(
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour
// Exact per-mode learning always wins — a real measurement for THIS mode.
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
// unlearned mode never inflates the estimate past a real sibling measurement.
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
val sibling = siblingScaledRate(profile, device, bucket, slot)
return listOfNotNull(unknown, sibling).maxOrNull()
}
/**
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
*
* Only fires when:
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
* conservative spec-min behaviour), and
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
* or display clamp, so a borrowed rate could over-promise unbounded), and
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
* hardware can't use), falling back to all modes only when the supported list is unavailable.
*
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
* merges the result conservatively with the UNKNOWN reading.
*/
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
if (bucket == MODE_UNKNOWN) return null
val targetSpec = device.specRate(bucket) ?: return null
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
val best = AapSetting.AncMode.Value.entries
.map { it.name }
.filter { it != bucket && (supported == null || it in supported) }
.mapNotNull { sib ->
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
val learned = profile.rates[rateKey(sib, slot)]
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
?: return@mapNotNull null
learned to siblingSpec
}
.maxWithOrNull(
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
.thenBy { -abs(targetSpec - it.second) }
.thenBy { it.first.updatedAt },
) ?: return null
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
}
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
fractionPerHour.takeIf { it.isFinite() && it > 0f }
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -223,6 +224,8 @@ class PlayPause @Inject constructor(
rawDecision = confirmation.decision,
currentState = currState,
autoPauseEnabled = reactions.autoPause,
now = current.ble?.seenLastAt,
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
)
pendingPauseDebounce = debounceResult.pending
@@ -240,7 +243,7 @@ class PlayPause @Inject constructor(
"rawShouldPlay=${confirmation.decision.shouldPlay}"
}
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
"Pause debounce committed: source=$source confirmed pause"
"Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
}
PauseDebounceEvent.NONE -> {}
}
@@ -438,12 +441,21 @@ class PlayPause @Inject constructor(
}
/**
* Sample-count debounce for pause decisions when the ear-detection source is an
* unauthenticated BLE advertisement.
* Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
* is an unauthenticated BLE advertisement.
*
* RF interference can produce a single corrupt advert that decodes as not-worn,
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
* 3 consecutive not-worn samples before firing.
* RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
* false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
* samples before firing on the *count* path.
*
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
*
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
* — subsequent samples after the initial detection are not-worn → not-worn, and
@@ -462,6 +474,8 @@ class PlayPause @Inject constructor(
rawDecision: PlayPauseDecision,
currentState: EarDetectionState,
autoPauseEnabled: Boolean,
now: Instant? = null,
generatedAtNanos: Long? = null,
): PauseDebounceResult {
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
@@ -525,6 +539,8 @@ class PlayPause @Inject constructor(
profileId = profileId,
initialPodCount = currentState.podCount,
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
startedAt = now,
startedGeneratedAtNanos = generatedAtNanos,
),
event = PauseDebounceEvent.STARTED,
)
@@ -545,7 +561,10 @@ class PlayPause @Inject constructor(
shouldPause = false,
reason = "Debouncing pause (rebound tolerated)",
),
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
pending = activePending.copy(
resetTolerance = activePending.resetTolerance - 1,
reboundTolerated = true,
),
event = PauseDebounceEvent.ADVANCED,
)
}
@@ -554,12 +573,41 @@ class PlayPause @Inject constructor(
// Confirmation: count <= initialPodCount, decrement remaining.
val remaining = activePending.confirmationsRemaining - 1
if (remaining <= 0) {
val commitOnCount = remaining <= 0
// Time-cap early commit: once the not-worn condition has persisted at least
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
// the "≥2 consecutive, distinct not-worn receptions" invariant:
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
// path is disabled and we fall back to pure sample-count.
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
// broken/constant OEM timebase just disables the time path (fail-safe to count).
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
// only delay, never prematurely fire.
val startedAt = activePending.startedAt
val startedNanos = activePending.startedGeneratedAtNanos
val elapsed = if (startedAt != null && now != null) {
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
} else {
null
}
val commitOnTime = !activePending.reboundTolerated &&
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
startedNanos != null && generatedAtNanos != null &&
generatedAtNanos != startedNanos
if (commitOnCount || commitOnTime) {
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
return PauseDebounceResult(
decision = PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
reason = "Debounced pause confirmed via $mode " +
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
),
pending = null,
event = PauseDebounceEvent.COMMITTED,
@@ -674,6 +722,18 @@ class PlayPause @Inject constructor(
// shows a pod returning shouldn't kill the pending, since the next sample may
// confirm the pods are still out.
val resetTolerance: Int = 1,
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
// not-worn sample. null → the time-cap early commit is inactive for this pending.
val startedAt: Instant? = null,
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
// not-worn sample. The time-cap commit requires the confirming sample to be a
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
// same cached advert in a later batch cannot early-commit on one physical advert.
val startedGeneratedAtNanos: Long? = null,
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
// (the not-worn samples are no longer strictly consecutive), falling back to pure
// sample-count confirmation.
val reboundTolerated: Boolean = false,
)
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
@@ -801,5 +861,19 @@ class PlayPause @Inject constructor(
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
*/
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
/**
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
* not-worn condition has persisted this long (measured from the first not-worn sample's
* observation time) with at least one further *distinct* not-worn reception and no
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
*
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
* see no change (count commits first). Tunable.
*/
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
}
}
@@ -39,6 +39,8 @@ class BatteryEstimatorTest : BaseTest() {
estimateEnabled: Boolean = true,
worn: Boolean = false,
systemConnected: Boolean = false,
ancMode: AapSetting.AncMode.Value? = null,
ancSupported: List<AapSetting.AncMode.Value> = AapSetting.AncMode.Value.entries,
): PodDevice {
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -49,14 +51,19 @@ class BatteryEstimatorTest : BaseTest() {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
}
val settings = if (worn) {
mapOf<kotlin.reflect.KClass<out AapSetting>, AapSetting>(
AapSetting.EarDetection::class to AapSetting.EarDetection(
val settings = buildMap<kotlin.reflect.KClass<out AapSetting>, AapSetting> {
if (worn) put(
AapSetting.EarDetection::class,
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)
),
)
} else emptyMap()
if (ancMode != null) put(
AapSetting.AncMode::class,
AapSetting.AncMode(current = ancMode, supported = ancSupported),
)
}
return PodDevice(
profileId = profileId,
ble = null,
@@ -554,6 +561,251 @@ class BatteryEstimatorTest : BaseTest() {
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `an empty ANC bucket borrows the sibling rate instead of jumping to spec`() = runTest(UnconfinedTestDispatcher()) {
// Pro 2: OFF learned (5h-equivalent), user toggles to ON whose bucket is empty. Both modes
// rate at 6h so the scale is 1 — ON reuses OFF's measured 0.20/hr (300 min) rather than the
// optimistic 6h spec (360). This is the +1h "ANC increases battery" paradox, removed.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `an empty bucket prefers the more conservative of UNKNOWN and the sibling`() = runTest(UnconfinedTestDispatcher()) {
// A mode-agnostic UNKNOWN reading (0.12/hr, optimistic) AND a real OFF sibling (0.20/hr) both
// exist while ON is empty. The estimate takes the less-optimistic of the two so a toggle can't
// inflate past the sibling: 0.20/hr -> 300, not the 360 the optimistic UNKNOWN would clamp to.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to learned(0.12f), "UNKNOWN/RIGHT" to learned(0.12f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `a borrowed sibling rate is scaled by the modes' rated drain`() = runTest(UnconfinedTestDispatcher()) {
// AirPods Pro (gen1) rates ANC on at 4.5h, off at 5h. An empty ON bucket borrows the OFF
// learned 0.20/hr and scales it by (1/4.5)/(1/5) == 1.111 -> 0.222/hr -> 270 min (4.5h). ANC
// on shows LESS than OFF's 300 min, the physically correct direction.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 270
}
@Test
fun `sibling scaling works in the inverse direction too`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro: only ON learned (0.30/hr). An empty OFF bucket borrows it scaled by
// (1/5)/(1/4.5) == 0.9 -> 0.27/hr -> 222 min. OFF drains slower than the measured ON, correct.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.OFF))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 222
}
@Test
fun `an empty bucket with no sibling still falls back to spec`() = runTest(UnconfinedTestDispatcher()) {
// Nothing learned in any mode -> the fallback can't fire, the model rating seeds as before.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a populated current bucket is never overridden by a sibling`() = runTest(UnconfinedTestDispatcher()) {
// Real ON data (0.30/hr) exists alongside OFF (0.20/hr). The current mode's own measurement
// wins outright -> 200 min; a genuine per-mode difference is preserved, not flattened.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 200
}
@Test
fun `the best-evidenced sibling is chosen`() = runTest(UnconfinedTestDispatcher()) {
// ON empty; OFF (0.20/hr, 1 update) and TRANSPARENCY (0.40/hr, 5 updates) both available and
// same-rated (all non-off modes rate 6h on a Pro 2, so scale 1). The higher-evidence
// TRANSPARENCY rate wins -> 150 min, not the 300 the thinner OFF rate would give.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 1), "OFF/RIGHT" to learned(0.20f, updateCount = 1),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 5), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 5),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence siblings tie-break on closest rated drain`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro rates ON and TRANSPARENCY at 4.5h but OFF at 5h. With equal evidence, the sibling
// whose rating is closest to ON (TRANSPARENCY, identical rating) is the better predictor and
// wins over OFF: 0.40/hr -> 150. Had OFF (0.20/hr) won, scaling would give 0.222/hr -> 270.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 3), "OFF/RIGHT" to learned(0.20f, updateCount = 3),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 3), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 3),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence equal-rated siblings tie-break on recency`() = runTest(UnconfinedTestDispatcher()) {
// TRANSPARENCY and ADAPTIVE both rate identically to ON (4.5h) with equal evidence — only
// recency separates them. The newer ADAPTIVE (0.50/hr) wins over the older TRANSPARENCY
// (0.40/hr): 0.50/hr -> 120, not 150.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"ADAPTIVE/LEFT" to learned(0.50f, updateCount = 2, updatedAt = now),
"ADAPTIVE/RIGHT" to learned(0.50f, updateCount = 2, updatedAt = now),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 120
}
@Test
fun `the UNKNOWN bucket does not borrow sibling rates`() = runTest(UnconfinedTestDispatcher()) {
// BLE-only (mode not known) keeps its conservative spec-min behaviour: a real OFF sibling is
// NOT borrowed, the estimate stays on the 6h rating (360), not OFF's 300.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a model without ratings does not borrow a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Beats Fit Pro has ANC but no published battery rating -> no spec ceiling to clamp a borrowed
// rate, so the fallback is skipped entirely and nothing over-promises (no estimate at all).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.BEATS_FIT_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
) shouldBe emptyMap()
}
@Test
fun `an unsupported sibling mode is not borrowed`() = runTest(UnconfinedTestDispatcher()) {
// A stale ADAPTIVE key exists, but the device only reports OFF/ON as supported -> the stale
// key is ignored, no other sibling has data, so the estimate stays on spec (360).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ADAPTIVE/LEFT" to learned(0.20f), "ADAPTIVE/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(
listOf(
device(
"p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2,
ancMode = AapSetting.AncMode.Value.ON,
ancSupported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
)
)
),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `the in-case runtime projection also borrows a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Charging (no live drain) in an empty ON bucket: the "if used now" projection borrows the OFF
// sibling (0.20/hr) instead of spec. At 50% that's 0.50 / 0.20 * 60 == 150.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 150
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -577,9 +829,10 @@ class BatteryEstimatorTest : BaseTest() {
estimator.estimates.value.containsKey("p1") shouldBe false
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
private fun learned(rate: Float, updateCount: Int = 1, updatedAt: Instant = now) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
updatedAt = now,
updateCount = updateCount,
updatedAt = updatedAt,
)
}
@@ -1,6 +1,7 @@
package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
@@ -1507,6 +1508,203 @@ class PlayPauseLogicTest : BaseTest() {
result.decision shouldBe noopDecision
result.pending shouldBe null
}
// --- Time-cap early commit (slow-scanner mitigation) ---
private val t0 = Instant.parse("2026-01-01T00:00:00Z")
@Test
fun `time-cap - slow cadence commits early on the first distinct confirmation past the cap`() {
// STARTED at t0 (reception nanos=100). The first confirmation arrives 2000ms later
// (> 1500ms cap) as a DISTINCT reception (nanos=200) → commit via time-cap on the
// first confirmation, before PAUSE_DEBOUNCE_SAMPLES would have fired.
val started = playPause.applyPauseDebounce(
pending = null,
profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0,
generatedAtNanos = 100L,
)
started.event shouldBe PlayPause.PauseDebounceEvent.STARTED
started.pending!!.startedAt shouldBe t0
started.pending!!.startedGeneratedAtNanos shouldBe 100L
val result = playPause.applyPauseDebounce(
pending = started.pending,
profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0.plusMillis(2000),
generatedAtNanos = 200L,
)
result.decision.shouldPause shouldBe true
result.pending shouldBe null
result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
result.decision.reason.contains("via time") shouldBe true
}
@Test
fun `time-cap - fast cadence still commits on sample count, not time`() {
// Per-advert cadence (batching disabled): confirmations at +150ms and +300ms, both
// under the cap → the pause commits on the 2nd confirmation via count, exactly as
// without the time-cap. Guards the recommended "Disable hardware batching" fast path.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val confirm1 = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(150), generatedAtNanos = 2L,
)
confirm1.decision.shouldPause shouldBe false
confirm1.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
// startedAt / startedGeneratedAtNanos are preserved across an ADVANCED.
confirm1.pending!!.startedAt shouldBe t0
confirm1.pending!!.startedGeneratedAtNanos shouldBe 1L
val confirm2 = playPause.applyPauseDebounce(
pending = confirm1.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(300), generatedAtNanos = 3L,
)
confirm2.decision.shouldPause shouldBe true
confirm2.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
confirm2.decision.reason.contains("via count") shouldBe true
}
@Test
fun `time-cap - identical reception (same generatedAtNanos) does not time-commit`() {
// A janky OEM re-delivers the SAME cached advert in a later batch callback: fresh
// seenLastAt but identical generatedAtNanos. The distinct-reception guard must block
// the time path — one physical advert cannot early-commit a pause.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 100L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2000), generatedAtNanos = 100L,
)
result.decision.shouldPause shouldBe false
result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
}
@Test
fun `time-cap - boundary elapsed exactly equal to the cap commits`() {
// Pins the comparison to >= (not >).
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0.plus(PlayPause.PAUSE_DEBOUNCE_TIME_CAP),
generatedAtNanos = 2L,
)
result.decision.shouldPause shouldBe true
result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
}
@Test
fun `time-cap - backward wall-clock does not time-commit (elapsed clamped to zero)`() {
// seenLastAt regresses (wall-clock jump, or the backing snapshot switching to a
// different physical device under BLE_PROFILE_FALLBACK). Clamp to >=0 → elapsed 0 →
// no time commit; must ADVANCE and wait for the count.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(5000), generatedAtNanos = 1L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 2L,
)
result.decision.shouldPause shouldBe false
result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
}
@Test
fun `time-cap - a tolerated rebound disables the time path but count still fires`() {
// STARTED t0; a count-up rebound at +1000 is tolerated (reboundTolerated=true). A
// not-worn at +2500 is past the cap but must NOT time-commit — the not-worn samples
// are no longer strictly consecutive. It ADVANCES; a further not-worn commits on count.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val rebound = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(true, false), // pod returned (count up)
autoPauseEnabled = true, now = t0.plusMillis(1000), generatedAtNanos = 2L,
)
rebound.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
rebound.pending!!.reboundTolerated shouldBe true
val pastCap = playPause.applyPauseDebounce(
pending = rebound.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2500), generatedAtNanos = 3L,
)
pastCap.decision.shouldPause shouldBe false
pastCap.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
val committed = playPause.applyPauseDebounce(
pending = pastCap.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2700), generatedAtNanos = 4L,
)
committed.decision.shouldPause shouldBe true
committed.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
}
}
@Nested
@@ -1708,13 +1906,21 @@ class PlayPauseLogicTest : BaseTest() {
@Nested
inner class MonitorFlowTests {
private fun buildBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) =
private fun buildBle(
seenAt: Instant,
leftWorn: Boolean,
rightWorn: Boolean,
genNanos: Long = 0L,
) =
mockk<DualApplePods>(relaxed = true) {
every { meta } returns ApplePods.AppleMeta(
isIRKMatch = false,
profile = mockk(relaxed = true),
)
every { seenLastAt } returns seenAt
every { scanResult } returns mockk<BleScanResult>(relaxed = true) {
every { generatedAtNanos } returns genNanos
}
every { isLeftPodInEar } returns leftWorn
every { isRightPodInEar } returns rightWorn
every { isBeingWorn } returns (leftWorn && rightWorn)
@@ -1731,9 +1937,10 @@ class PlayPauseLogicTest : BaseTest() {
leftWorn: Boolean,
rightWorn: Boolean,
startMusicOnWear: Boolean = true,
genNanos: Long = 0L,
) = PodDevice(
profileId = "test-profile",
ble = buildBle(seenAt, leftWorn, rightWorn),
ble = buildBle(seenAt, leftWorn, rightWorn, genNanos),
aap = null,
profileModel = PodModel.AIRPODS_PRO3,
reactions = ReactionConfig(
@@ -1784,6 +1991,9 @@ class PlayPauseLogicTest : BaseTest() {
@Test
fun `flow - stable worn rebound resets stale pause debounce before a new removal sequence`() = runTest {
// NOTE: buildDevice defaults genNanos = 0L, so every sample here shares one
// generatedAtNanos. The time-cap's distinct-reception guard is therefore inert and
// this test exercises the pure sample-count path — independent of PAUSE_DEBOUNCE_TIME_CAP.
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow
@@ -1834,6 +2044,82 @@ class PlayPauseLogicTest : BaseTest() {
job.cancel()
}
@Test
fun `flow - slow cadence commits pause via time-cap on the second distinct not-worn sample`() = runTest {
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow
}
val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) {
every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true)))
}
val mediaControl: MediaControl = mockk(relaxed = true) {
every { isPlaying } returns true
every { wasRecentlyPausedByCap } returns false
coEvery { sendPause(rememberForResume = true) } returns true
}
val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl)
val now = Instant.parse("2026-01-01T00:00:00Z")
val job = launch { flowPlayPause.monitor().collect {} }
// T0: worn baseline.
deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L))
advanceUntilIdle()
// T1: first not-worn sample starts the debounce (seenLastAt = t0+1000).
deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = false, rightWorn = false, genNanos = 2L))
advanceUntilIdle()
// T2: a second, DISTINCT not-worn reception 2000ms after the first (> 1500ms cap).
// Only two not-worn samples so far — the pure sample count would need a third — but
// on a slow (~2s) batch cadence the time-cap commits the pause here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(3000), leftWorn = false, rightWorn = false, genNanos = 3L))
advanceUntilIdle()
coVerify(exactly = 1) { mediaControl.sendPause(rememberForResume = true) }
job.cancel()
}
@Test
fun `flow - two not-worn samples within the cap do not pause (elapsed anchored at first not-worn)`() = runTest {
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow
}
val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) {
every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true)))
}
val mediaControl: MediaControl = mockk(relaxed = true) {
every { isPlaying } returns true
every { wasRecentlyPausedByCap } returns false
coEvery { sendPause(rememberForResume = true) } returns true
}
val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl)
val now = Instant.parse("2026-01-01T00:00:00Z")
val job = launch { flowPlayPause.monitor().collect {} }
// T0: worn baseline.
deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L))
advanceUntilIdle()
// T1: first not-worn at t0+1400 → debounce STARTED, elapsed anchored here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(1400), leftWorn = false, rightWorn = false, genNanos = 2L))
advanceUntilIdle()
// T2: second not-worn at t0+2000. Elapsed from the FIRST not-worn is 600ms (< cap),
// and only two samples → no pause. A wrong anchor (baseline t0, or a device-level
// timestamp) would read 2000ms >= cap and wrongly pause here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(2000), leftWorn = false, rightWorn = false, genNanos = 3L))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPause(rememberForResume = true) }
job.cancel()
}
private fun buildIrkMatchedBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) =
mockk<DualApplePods>(relaxed = true) {
every { meta } returns ApplePods.AppleMeta(
+1 -1
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ###
project.versioning.major=5
project.versioning.minor=2
project.versioning.patch=0
project.versioning.patch=1
project.versioning.build=0
project.versioning.type=rc
#############################