mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
feat: Add earbud battery time-remaining estimate
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user