feat(battery): Model charge taper per band, base health on listening-only drain

- Replace the single linear charge rate with a three-band model (bulk / taper /
  trickle) matching lithium CC/CV charging: each band learns its own rate, the
  ETA walks the remaining bands, and the spec seed gets a taper haircut for the
  slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
  worn, audio is playing, AND this device is the system's audio sink; idle wear
  previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
  (playback stop, docking, transport flip) instead of being discarded with the
  cleared window
- The time-remaining estimate keeps learning from all usage — actual current
  drain, idle included, is the right basis for "how long will they last"
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 5de5dee52f
commit afba33dd83
9 changed files with 520 additions and 87 deletions
@@ -558,7 +558,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
@@ -585,7 +585,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
@@ -6,6 +6,7 @@ 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.protocol.AapSetting
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
@@ -36,6 +37,8 @@ class BatteryEstimatorTest : BaseTest() {
optimized: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
worn: Boolean = false,
systemConnected: Boolean = false,
): PodDevice {
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -46,12 +49,21 @@ 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(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)
)
} else emptyMap()
return PodDevice(
profileId = profileId,
ble = null,
aap = AapPodState(batteries = batteries),
aap = AapPodState(batteries = batteries, settings = settings),
profileModel = model,
batteryEstimateEnabled = estimateEnabled,
isSystemConnected = systemConnected,
)
}
@@ -59,6 +71,7 @@ class BatteryEstimatorTest : BaseTest() {
emissions: List<List<PodDevice>>,
stored: Map<String, DrainProfile> = emptyMap(),
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
musicActive: List<Boolean> = List(emissions.size) { false },
): BatteryEstimator {
val deviceMonitor = mockk<DeviceMonitor> {
every { devices } returns flowOf(*emissions.toTypedArray())
@@ -71,7 +84,10 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returnsMany clockMs
every { now() } returns now
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> {
every { isMusicActive } returnsMany musicActive
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
}
/**
@@ -270,11 +286,36 @@ class BatteryEstimatorTest : BaseTest() {
@Test
fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) {
// Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim
// (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min.
// (2.0/hr for a Pro 2) seeds the bands with the taper haircut: 30% of bulk at 2.0/hr (9m)
// + taper at 1.0/hr (6m) + trickle at 0.6/hr (10m) == 25 min.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 25
}
@Test
fun `learned band rates shape the ETA through the taper`() = runTest(UnconfinedTestDispatcher()) {
// At 85% the linear scalar (1.2/hr) would claim 8m; the learned bands know the taper is
// slower: 5% of taper at 1.0/hr (3m) + trickle at 0.6/hr (10m) == 13m.
val bands = mapOf(
"BULK" to learned(2.0f),
"TAPER" to learned(1.0f),
"TRICKLE" to learned(0.6f),
)
val stored = mapOf(
"p1" to DrainProfile(
chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)),
chargeBands = mapOf("LEFT" to bands, "RIGHT" to bands),
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.85f, right = 0.85f, charging = true, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 13
}
@Test
@@ -345,7 +386,7 @@ class BatteryEstimatorTest : BaseTest() {
}
@Test
fun `charge rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
fun `charge rates and band rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
@@ -359,12 +400,122 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
estimator.monitor().collect {}
coVerify {
drainStore.save("p1", match { it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") })
drainStore.save("p1", match {
it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") &&
it.chargeBands["LEFT"]?.containsKey("BULK") == true
})
}
}
@Test
fun `worn playing segments feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Steady discharge while worn, playing, and system-connected: learned into BOTH the
// general rates and the health-grade listening rates.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match {
it.listeningRates.containsKey("UNKNOWN/LEFT") && it.rates.containsKey("UNKNOWN/LEFT")
})
}
}
@Test
fun `idle wear does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Worn and connected but nothing playing: general rates learn, listening rates stay empty.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
}
}
@Test
fun `playback on another sink does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Music is playing but this device is NOT the system's audio sink (phone speaker, car):
// treating it as pod listening would poison health.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = false))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
}
}
@Test
fun `a listening segment is flushed when playback stops`() = runTest(UnconfinedTestDispatcher()) {
// 1-minute cadence: fit persists at the 4th sample (t=3), further drops are inside the
// persistence cooldown — then playback stops. The closed segment must be flushed and
// persisted anyway, not silently discarded with the cleared window.
val worn = (0 until 5).map { i ->
listOf(device("p1", left = 0.80f - i * 0.01f, right = 0.80f - i * 0.01f, worn = true, systemConnected = true))
}
val after = listOf(listOf(device("p1", left = 0.75f, right = 0.75f, worn = true, systemConnected = true)))
val emissions = worn + after
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> {
every { isMusicActive } returnsMany listOf(true, true, true, true, true, false)
}
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
// Two listening persists: the cadence one mid-segment, and the forced flush at gate-off.
coVerify(atLeast = 2) {
drainStore.save("p1", match { it.listeningRates.containsKey("UNKNOWN/LEFT") })
}
}
@@ -417,7 +568,8 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returns 0L
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
estimator.reset("p1")
@@ -21,7 +21,7 @@ class BatteryHealthTest : BaseTest() {
@Test
fun `health is the ratio of rated to learned drain`() {
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@@ -30,7 +30,7 @@ class BatteryHealthTest : BaseTest() {
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
// diverge — each pod gets its own figure instead of one masking the other.
val profile = DrainProfile(
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
@@ -44,7 +44,7 @@ class BatteryHealthTest : BaseTest() {
@Test
fun `health is capped at 100`() {
// Idle-heavy usage drains slower than the listening rating — never report over-health.
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
}
@@ -54,7 +54,7 @@ class BatteryHealthTest : BaseTest() {
// so a single gentle idle session can't inflate the figure and one hard session can't
// tank it.
val profile = DrainProfile(
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 6f),
"ON/LEFT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
@@ -66,14 +66,14 @@ class BatteryHealthTest : BaseTest() {
@Test
fun `rates without enough accumulated sessions are ignored`() {
val profile = DrainProfile(
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `models without a rating have no health`() {
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
}
@@ -87,7 +87,7 @@ class BatteryHealthTest : BaseTest() {
fun `rates learned on different hardware are ignored`() {
val profile = DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@@ -95,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
@Test
fun `malformed bucket keys and broken rates are skipped`() {
val profile = DrainProfile(
rates = mapOf(
listeningRates = mapOf(
"GARBAGE/LEFT" to rate(1f / 3f), // unrecognized bucket
"UNKNOWN" to rate(1f / 3f), // no slot at all
"UNKNOWN/" to rate(1f / 3f), // blank slot
@@ -112,14 +112,14 @@ class BatteryHealthTest : BaseTest() {
fun `mode-specific rates are judged against their own rating`() {
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
// ON rating — not 40% of the OFF one.
val profile = DrainProfile(rates = mapOf("ON/LEFT" to rate(0.5f)))
val profile = DrainProfile(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
val profile = DrainProfile(rates = mapOf("ON/HEADSET" to rate(0.1f)))
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
health.left shouldBe null
@@ -165,18 +165,74 @@ class DrainModelTest : BaseTest() {
@Test
fun `minutesUntilFull divides the missing fraction by the rate`() {
// 40% missing at 1.2/hr -> 0.4 / 1.2 * 60 = 20 minutes. A fraction, never a percent.
DrainModel.minutesUntilFull(0.60f, 1.2f) shouldBe 20
// Uniform 1.2/hr across all bands: 40% missing -> 0.4 / 1.2 * 60 = 20 minutes.
// A fraction, never a percent.
DrainModel.minutesUntilFull(0.60f) { 1.2f } shouldBe 20
}
@Test
fun `minutesUntilFull suppresses the trickle zone`() {
DrainModel.minutesUntilFull(0.98f, 1.2f).shouldBeNull()
fun `minutesUntilFull walks the remaining bands at their own rates`() {
// At 85%: 5% of taper at 1.0/hr (3m) + 10% of trickle at 0.6/hr (10m) = 13m. The bulk
// band is already behind and must not contribute.
val rates = mapOf(
DrainModel.ChargeBand.BULK to 2.0f,
DrainModel.ChargeBand.TAPER to 1.0f,
DrainModel.ChargeBand.TRICKLE to 0.6f,
)
DrainModel.minutesUntilFull(0.85f) { rates[it] } shouldBe 13
}
@Test
fun `minutesUntilFull needs a rate for every remaining band`() {
// Bulk known but the taper band has no basis -> no honest ETA.
DrainModel.minutesUntilFull(0.50f) { band ->
if (band == DrainModel.ChargeBand.BULK) 2.0f else null
}.shouldBeNull()
}
@Test
fun `minutesUntilFull suppresses the near-full sliver`() {
DrainModel.minutesUntilFull(0.995f) { 1.2f }.shouldBeNull()
}
@Test
fun `minutesUntilFull rejects a non-positive rate`() {
DrainModel.minutesUntilFull(0.60f, 0f).shouldBeNull()
DrainModel.minutesUntilFull(0.60f) { 0f }.shouldBeNull()
}
@Test
fun `band fits only use samples inside the band`() {
// Bulk samples rise fast (2.4/hr), then the taper crawls: two in-band taper points
// 24 minutes apart -> 0.25/hr... below the taper floor? floor = 0.25 * 0.5 = 0.125, ok.
val samples = listOf(
DrainSample(0L, 0.60f),
DrainSample(5 * 60_000L, 0.70f),
DrainSample(10 * 60_000L, 0.80f),
DrainSample(34 * 60_000L, 0.90f),
)
val taper = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.TAPER)
taper.shouldNotBeNull()
taper shouldBe (0.25f plusOrMinus 0.01f)
// The bulk fit must not be dragged down by the slow taper points beyond its range.
val bulk = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.BULK)
bulk.shouldNotBeNull()
(bulk > 1.0f) shouldBe true
}
@Test
fun `a narrow band accepts a two-point fit but the bulk band does not`() {
val twoTaperPoints = listOf(
DrainSample(0L, 0.80f),
DrainSample(12 * 60_000L, 0.90f), // 0.5/hr
)
DrainModel.chargeBandSlopeFractionPerHour(twoTaperPoints, DrainModel.ChargeBand.TAPER)
.shouldNotBeNull()
val twoBulkPoints = listOf(
DrainSample(0L, 0.40f),
DrainSample(12 * 60_000L, 0.50f),
)
DrainModel.chargeBandSlopeFractionPerHour(twoBulkPoints, DrainModel.ChargeBand.BULK)
.shouldBeNull()
}
@Test
@@ -28,6 +28,8 @@ class DrainProfileSerializationTest : BaseTest() {
profile.model shouldBe null
profile.chargeRates shouldBe emptyMap()
profile.chargeBands shouldBe emptyMap()
profile.listeningRates shouldBe emptyMap()
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
}
@@ -51,6 +53,24 @@ class DrainProfileSerializationTest : BaseTest() {
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
chargeBands = mapOf(
"LEFT" to mapOf(
"TAPER" to DrainProfile.LearnedRate(
fractionPerHour = 0.9f,
sampleCount = 3,
updateCount = 2,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
)
),
listeningRates = mapOf(
"ON/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 0.24f,
sampleCount = 7,
updateCount = 3,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
)
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile