feat(battery): Make estimate per-device and seed from model specs

- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
  the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
  Battery card; the charge notification now works for any live device, not only classic
  audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
  drain stops over-promising within a couple of updates
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 9606a33f15
commit aae8ad62bd
26 changed files with 718 additions and 176 deletions
@@ -12,6 +12,7 @@ import eu.darken.capod.main.core.MonitorMode
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.BatteryEstimator
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
@@ -62,6 +63,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var batteryEstimator: BatteryEstimator
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
@@ -116,6 +118,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
profilesRepo = mockk(relaxed = true) {
every { profiles } returns profilesFlow
}
batteryEstimator = mockk(relaxed = true)
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
@@ -140,6 +143,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
batteryEstimator = batteryEstimator,
monitorModeResolver = monitorModeResolver,
nudgeCapabilityStore = nudgeCapabilityStore,
timeSource = timeSource,
@@ -514,6 +518,45 @@ class DeviceSettingsViewModelTest : BaseTest() {
coVerify(exactly = 0) { aapManager.sendCommand(any(), any<AapCommand.SetSleepDetection>()) }
}
@Test
fun `state reflects the profile's batteryEstimateEnabled`() = runVmTest {
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryEstimateEnabled shouldBe false
}
@Test
fun `setBatteryEstimateEnabled updates the profile`() = runVmTest {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setBatteryEstimateEnabled(false)
coVerify { profilesRepo.updateAppleProfile(testAddress, any()) }
}
@Test
fun `resetBatteryEstimate resets the estimator for the profile`() = runVmTest {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.resetBatteryEstimate()
coVerify { batteryEstimator.reset(testAddress) }
}
@Test
fun `setSleepDetection(false) as non-Pro still sends command`() = runVmTest {
// Disabling must work regardless of pro status so users who enabled it
@@ -71,7 +71,6 @@ 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() {
@@ -87,7 +86,6 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -106,7 +104,6 @@ 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 {
@@ -650,4 +647,46 @@ class OverviewViewModelTest : BaseTest() {
latest!!.showTroubleshootSuggestion shouldBe false
}
}
@Test
fun `estimateFor is null when the device has the estimate disabled`() {
val device = mockk<PodDevice> {
every { batteryEstimateEnabled } returns false
every { isLive } returns true
every { profileId } returns "p1"
}
val state = estimateState(device, mapOf("p1" to sampleEstimate()))
state.estimateFor(device) shouldBe null
}
@Test
fun `estimateFor returns the estimate when enabled and live`() {
val device = mockk<PodDevice> {
every { batteryEstimateEnabled } returns true
every { isLive } returns true
every { profileId } returns "p1"
}
val estimate = sampleEstimate()
val state = estimateState(device, mapOf("p1" to estimate))
state.estimateFor(device) shouldBe estimate
}
private fun sampleEstimate() = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 120, fractionPerHour = 0.2f, source = BatteryEstimate.Source.LIVE),
)
private fun estimateState(device: PodDevice, estimates: Map<String, BatteryEstimate>) =
OverviewViewModel.State(
now = java.time.Instant.EPOCH,
permissions = emptySet(),
devices = listOf(device),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
)
}
@@ -3,6 +3,7 @@ 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.PodModel
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
@@ -10,6 +11,7 @@ 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.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,13 +33,21 @@ class BatteryEstimatorTest : BaseTest() {
left: Float?,
right: Float?,
charging: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
): 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))
return PodDevice(
profileId = profileId,
ble = null,
aap = AapPodState(batteries = batteries),
profileModel = model,
batteryEstimateEnabled = estimateEnabled,
)
}
private fun estimator(
@@ -86,7 +96,8 @@ class BatteryEstimatorTest : BaseTest() {
}
@Test
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
fun `a charging device projects runtime from the learned rate`() = runTest(UnconfinedTestDispatcher()) {
// Docked/charging: no live drain, but we still show "what it'd last if used now" from history.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
@@ -94,7 +105,29 @@ class BatteryEstimatorTest : BaseTest() {
stored = stored,
)
)
result shouldBe emptyMap()
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@Test
fun `a charging device projects runtime from the model rating`() = runTest(UnconfinedTestDispatcher()) {
// Full AirPods Pro 2 in the case, nothing learned yet -> projects the 6h rating. 1.0 / (1/6) * 60.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a charging device with no rate shows nothing`() = runTest(UnconfinedTestDispatcher()) {
// Unknown model, nothing learned -> no basis to project from while charging.
collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.80f, right = 0.80f, charging = true))))
) shouldBe emptyMap()
}
@Test
@@ -109,7 +142,7 @@ class BatteryEstimatorTest : BaseTest() {
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe true
left.source shouldBe BatteryEstimate.Source.LEARNED
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@@ -122,7 +155,7 @@ class BatteryEstimatorTest : BaseTest() {
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe false
left.source shouldBe BatteryEstimate.Source.LIVE
}
@Test
@@ -134,12 +167,112 @@ class BatteryEstimatorTest : BaseTest() {
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
left.source shouldBe BatteryEstimate.Source.LIVE
right.source shouldBe BatteryEstimate.Source.LIVE
// Faster-draining left pod must empty sooner than the right.
(left.minutesRemaining < right.minutesRemaining) shouldBe true
}
@Test
fun `a model rating seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
// One sample -> no live regression, nothing learned -> the AirPods Pro 2 rating (6h) seeds
// the estimate at once. 1.00 / (1/6) * 60 == 360.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `an unknown ANC mode seeds from the shorter rating`() = runTest(UnconfinedTestDispatcher()) {
// AirPods 4 ANC: 4h with ANC on, 5h off. The mode isn't known yet, so the shorter 4h rating
// is used to avoid over-promising. 1.00 / (1/4) * 60 == 240.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_GEN4_ANC))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 240
}
@Test
fun `the model rating caps an over-optimistic learned rate`() = runTest(UnconfinedTestDispatcher()) {
// A learned 0.10/hr implies 10h at full charge, beyond the Pro 2's 6h rating. The rating is a
// hard ceiling, so the shown estimate is capped at 6h (360), not 600.
val stored = mapOf(
"p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.10f), "UNKNOWN/RIGHT" to learned(0.10f)))
)
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.LEARNED
left.minutesRemaining shouldBe 360
}
@Test
fun `a live rate slower than the rating is capped to the rating but stays LIVE`() = runTest(UnconfinedTestDispatcher()) {
// Measured 15%/hr on an AirPods Pro (rated 4.5h == ~22%/hr): draining slower than Apple rates,
// so the shown life is capped to the 4.5h rating. At 0.76 that's 0.76 / (1/4.5) * 60 == 205
// (not the ~304 the raw 15%/hr would imply). The estimate is still measured, so source == LIVE.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LIVE
left.minutesRemaining shouldBe 205
}
@Test
fun `an implausibly fast live rate is rejected in favour of the rating`() = runTest(UnconfinedTestDispatcher()) {
// 5%/4min == 75%/hr, far beyond 4x the Pro 2 rating (~67%/hr max plausible), so the live fit
// is discarded and the estimate falls back to the model rating.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.05f
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO2))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `a device with the estimate disabled is not sampled`() = runTest(UnconfinedTestDispatcher()) {
// A clean steady discharge that WOULD yield a live estimate — but the feature is off.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, estimateEnabled = false))
}
collectEstimate(estimator(emissions)) shouldBe emptyMap()
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(
mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f))))
)
coEvery { save(any(), any()) } returns Unit
coEvery { delete(any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(emptyList()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returns 0L
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
estimator.reset("p1")
coVerify { drainStore.delete("p1") }
estimator.estimates.value.containsKey("p1") shouldBe false
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
@@ -94,6 +94,14 @@ class DrainModelTest : BaseTest() {
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
}
@Test
fun `blendMinutes reacts faster to drops than to rises`() {
// Drop (less time left): fast factor so we stop over-promising quickly. 0.6*180 + 0.4*360 = 252
DrainModel.blendMinutes(previous = 360, next = 180) shouldBe 252
// Rise (more time / noise): gentle factor so we don't jump up. 0.25*200 + 0.75*100 = 125
DrainModel.blendMinutes(previous = 100, next = 200) shouldBe 125
}
@Test
fun `blendRate seeds then smooths`() {
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
@@ -117,6 +117,44 @@ class ModelFeaturesTest : BaseTest() {
}
}
@Test
fun `battery specs are populated for exactly the Apple models`() {
PodModel.entries.filter { it.batterySpec != null }.toSet() shouldBe batterySpecModels
}
@Test
fun `ANC-capable models with a rating publish an ANC-on figure`() {
PodModel.entries
.filter { it.batterySpec != null && it.features.hasAncControl }
.forEach { model ->
withClue(model.name) {
(model.batterySpec?.listeningHoursAncOn != null) shouldBe true
}
}
}
@Test
fun `every battery spec has at least one rating`() {
PodModel.entries.mapNotNull { it.batterySpec }.forEach { spec ->
withClue(spec.toString()) {
(spec.listeningHoursAncOn != null || spec.listeningHoursAncOff != null) shouldBe true
}
}
}
@Test
fun `battery ratings are single-charge pod figures within a sane range`() {
// Guards against accidentally using Apple's "with charging case" aggregate (e.g. 30h).
PodModel.entries.forEach { model ->
val spec = model.batterySpec ?: return@forEach
listOfNotNull(spec.listeningHoursAncOn, spec.listeningHoursAncOff).forEach { hours ->
withClue("${model.name}: $hours") {
(hours in 1f..24f) shouldBe true
}
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
@@ -344,6 +382,21 @@ class ModelFeaturesTest : BaseTest() {
PodModel.POWERBEATS_PRO2,
)
private val batterySpecModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
@@ -27,6 +27,29 @@ class AppleDeviceProfileSerializationTest : BaseTest() {
profile.reactionConfig.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
}
@Test
fun `profiles stored before the battery estimate toggle default to enabled`() {
val legacyJson = """
{
"id": "test-id",
"label": "My Pods"
}
""".trimIndent()
val profile = json.decodeFromString<AppleDeviceProfile>(legacyJson)
profile.batteryEstimateEnabled shouldBe true
}
@Test
fun `battery estimate toggle round-trips`() {
val profile = AppleDeviceProfile(label = "My Pods", batteryEstimateEnabled = false)
val decoded = json.decodeFromString<AppleDeviceProfile>(json.encodeToString(profile))
decoded.batteryEstimateEnabled shouldBe false
}
@Test
fun `charged reaction settings round-trip`() {
val profile = AppleDeviceProfile(