feat: Add earbud battery time-remaining estimate

This commit is contained in:
darken
2026-07-01 17:58:12 +02:00
committed by Matthias Urhahn
parent f626d70538
commit 9606a33f15
21 changed files with 1072 additions and 28 deletions
@@ -12,6 +12,8 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.AppleDeviceProfile
@@ -56,6 +58,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var batteryEstimator: BatteryEstimator
private val timeSource: TimeSource = TestTimeSource()
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
@@ -68,6 +71,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
private lateinit var fakeBatteryEstimateEnabled: FakeDataStoreValue<Boolean>
@BeforeEach
fun setup() {
@@ -83,6 +87,7 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -101,6 +106,11 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
every { it.batteryEstimateEnabled } returns fakeBatteryEstimateEnabled.mock
}
batteryEstimator = mockk<BatteryEstimator>().also {
every { it.estimates } returns MutableStateFlow(emptyMap<String, BatteryEstimate>())
}
monitorModeResolver = mockk<MonitorModeResolver>().also {
@@ -139,6 +149,7 @@ class OverviewViewModelTest : BaseTest() {
profilesRepo = profilesRepo,
aapManager = mockk(relaxed = true),
monitorModeResolver = monitorModeResolver,
batteryEstimator = batteryEstimator,
timeSource = timeSource,
)
@@ -0,0 +1,148 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.TimeSource
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class BatteryEstimatorTest : BaseTest() {
private val now = Instant.parse("2026-04-02T12:00:00Z")
private fun device(
profileId: String?,
left: Float?,
right: Float?,
charging: Boolean = false,
): PodDevice {
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
val batteries = buildMap {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
}
return PodDevice(profileId = profileId, ble = null, aap = AapPodState(batteries = batteries))
}
private fun estimator(
emissions: List<List<PodDevice>>,
stored: Map<String, DrainProfile> = emptyMap(),
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
): BatteryEstimator {
val deviceMonitor = mockk<DeviceMonitor> {
every { devices } returns flowOf(*emissions.toTypedArray())
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(stored)
coEvery { save(any(), any()) } returns Unit
}
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany clockMs
every { now() } returns now
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
}
/**
* Runs the estimator over its (finite) device flow and returns the last non-empty estimate map
* seen *during* collection. monitor() clears estimates on completion (it stops with the service),
* so we capture the live value as it is produced rather than reading it after the flow ends.
*/
private suspend fun TestScope.collectEstimate(estimator: BatteryEstimator): Map<String, BatteryEstimate> {
val captured = mutableListOf<Map<String, BatteryEstimate>>()
backgroundScope.launch { estimator.estimates.collect { captured += it } }
estimator.monitor().collect {}
return captured.lastOrNull { it.isNotEmpty() } ?: emptyMap()
}
@Test
fun `cached-only device is not sampled`() = runTest(UnconfinedTestDispatcher()) {
// ble == null && aap == null -> not live -> ignored.
val offline = PodDevice(profileId = "p1", ble = null, aap = null)
collectEstimate(estimator(listOf(listOf(offline)))) shouldBe emptyMap()
}
@Test
fun `ambiguous same-profile devices are skipped`() = runTest(UnconfinedTestDispatcher()) {
val a = device("p1", left = 0.80f, right = 0.80f)
val b = device("p1", left = 0.50f, right = 0.50f)
collectEstimate(estimator(listOf(listOf(a, b)))) shouldBe emptyMap()
}
@Test
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true))),
stored = stored,
)
)
result shouldBe emptyMap()
}
@Test
fun `a learned rate seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
// One emission, only one sample -> no live regression -> must fall back to learned rate.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe true
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@Test
fun `a steady discharge yields a live estimate`() = runTest(UnconfinedTestDispatcher()) {
// 5 snapshots, 1% lost every 4 minutes -> 15%/hr -> 0.15 fraction/hr.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe false
}
@Test
fun `pods draining at different rates get independent estimates`() = runTest(UnconfinedTestDispatcher()) {
// Left drains faster (1.25%/step) than right (1%/step) over the same 4-minute steps.
val emissions = (0 until 5).map { i ->
listOf(device("p1", left = 0.80f - i * 0.0125f, right = 0.80f - i * 0.01f))
}
val estimate = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull()
val left = estimate.left.shouldNotBeNull()
val right = estimate.right.shouldNotBeNull()
left.isLearned shouldBe false
right.isLearned shouldBe false
// Faster-draining left pod must empty sooner than the right.
(left.minutesRemaining < right.minutesRemaining) shouldBe true
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
updatedAt = now,
)
}
@@ -0,0 +1,109 @@
package eu.darken.capod.monitor.core.battery
import io.kotest.matchers.floats.plusOrMinus
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class DrainModelTest : BaseTest() {
/** Samples draining at a constant rate, [perMinute] fraction lost per minute. */
private fun drainingSamples(
start: Float,
perMinute: Float,
count: Int,
stepMinutes: Long = 4,
): List<DrainSample> = (0 until count).map { i ->
DrainSample(
atElapsedMs = i * stepMinutes * 60_000L,
fraction = start - perMinute * (i * stepMinutes),
)
}
@Test
fun `slope recovers a constant drain rate in fraction per hour`() {
// 0.25% per minute == 15% per hour == 0.15 fraction/hour.
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))
rate.shouldNotBeNull()
rate shouldBe (0.15f plusOrMinus 0.01f)
}
@Test
fun `rate is a fraction not a percentage`() {
// Sanity guard against a 100x unit error: a ~15%/hr drain must be ~0.15, never ~15.
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))!!
(rate < 1f) shouldBe true
}
@Test
fun `too few samples yields null`() {
DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 3)).shouldBeNull()
}
@Test
fun `a too-short window is rejected`() {
// 4 samples 30s apart: enough points, but span < MIN_SPAN_MS.
val samples = (0 until 4).map { DrainSample(it * 30_000L, 0.80f - it * 0.01f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `a negligible drop is rejected`() {
// Long enough window but total drop below MIN_TOTAL_DROP.
val samples = (0 until 5).map { DrainSample(it * 5 * 60_000L, 0.80f - it * 0.002f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `a charging-style increase is not a drain`() {
val samples = (0 until 5).map { DrainSample(it * 4 * 60_000L, 0.50f + it * 0.02f) }
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `an implausibly fast drain is rejected`() {
// 4 rapid 1% ticks spanning > MIN_SPAN but dropping far too fast (~120%/hr).
val samples = (0 until 5).map { DrainSample(it * 60_000L, 0.80f - it * 0.02f) }
.let { it + DrainSample(it.size * 60_000L, 0.70f) } // keep span > 3 min
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `minutesRemaining divides level by rate`() {
// 50% left at 0.15/hr -> 0.5 / 0.15 * 60 = 200 minutes.
DrainModel.minutesRemaining(0.50f, 0.15f) shouldBe 200
}
@Test
fun `minutesRemaining rejects a non-positive rate`() {
DrainModel.minutesRemaining(0.50f, 0f).shouldBeNull()
}
@Test
fun `minutesRemaining suppresses absurd estimates`() {
// Extremely slow rate -> beyond MAX_MINUTES -> suppressed.
DrainModel.minutesRemaining(1.0f, 0.0001f).shouldBeNull()
}
@Test
fun `blendMinutes seeds then smooths`() {
DrainModel.blendMinutes(previous = null, next = 100) shouldBe 100
// 0.3 * 200 + 0.7 * 100 = 130
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
}
@Test
fun `blendRate seeds then smooths`() {
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
DrainModel.blendRate(previous = 0.1f, next = 0.2f, alpha = 0.3f) shouldBe (0.13f plusOrMinus 0.0001f)
}
@Test
fun `regression denominator is well conditioned for spread samples`() {
// Guard that real spread input produces a finite, positive rate (no divide-by-zero path).
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.90f, 0.003f, count = 8))!!
(rate.isFinite() && rate > 0f) shouldBe true
}
}
@@ -31,6 +31,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
generalSettings = mockk(relaxed = true),
settings = settings,
deviceStateCache = mockk(relaxed = true),
batteryDrainStore = mockk(relaxed = true),
json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true },
)