feat(battery): Add experimental case charge ETA and case battery health

- Show the time until the case is full inside its charging chip, learned from
  the case's own rising level with the existing charge-band model; no rating
  exists for case charging, so the first charge learns before it shows
- Derive a case battery health from observed transfer efficiency: pod percent
  gained per case percent spent while docked and unplugged, corrected by each
  pod's own health, compared against Apple's "with charging case" totals
- Case data is only genuine while a pod is docked — both transports silently
  freeze the last value otherwise. Battery updates now flag whether the case
  entry is live, and BLE gains strict same-frame case accessors, so estimates
  never learn from frozen echoes
- The case deliberately gets no runtime estimate: idle-then-burst drain has no
  meaningful hourly rate
- Mark the case metrics as experimental in the Battery settings card
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 21560a1f3a
commit d4dddb1f38
18 changed files with 515 additions and 20 deletions
@@ -23,6 +23,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
batteryHealth = "Battery Health",
leftBatteryHealth = "Left Battery Health",
rightBatteryHealth = "Right Battery Health",
caseBatteryHealth = "Case Battery Health",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
@@ -39,6 +39,9 @@ class BatteryEstimatorTest : BaseTest() {
estimateEnabled: Boolean = true,
worn: Boolean = false,
systemConnected: Boolean = false,
case: Float? = null,
caseCharging: Boolean = false,
caseLive: Boolean = true,
): PodDevice {
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -48,6 +51,10 @@ class BatteryEstimatorTest : BaseTest() {
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))
if (case != null) put(
BatteryType.CASE,
Battery(BatteryType.CASE, case, if (caseCharging) ChargingState.CHARGING else ChargingState.NOT_CHARGING),
)
}
val settings = if (worn) {
mapOf<kotlin.reflect.KClass<out AapSetting>, AapSetting>(
@@ -60,7 +67,7 @@ class BatteryEstimatorTest : BaseTest() {
return PodDevice(
profileId = profileId,
ble = null,
aap = AapPodState(batteries = batteries, settings = settings),
aap = AapPodState(batteries = batteries, settings = settings, caseIsLive = case != null && caseLive),
profileModel = model,
batteryEstimateEnabled = estimateEnabled,
isSystemConnected = systemConnected,
@@ -554,6 +561,94 @@ class BatteryEstimatorTest : BaseTest() {
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `a rising case charge yields a case ETA`() = runTest(UnconfinedTestDispatcher()) {
// 2%/min case rise -> 1.2/hr; at 44% that's (1 - 0.44) / 1.2 * 60 == 28 min. No spec
// seed exists for cases, so the live fit is the only source on a first charge.
val emissions = (0 until 4).map { i ->
listOf(device("p1", left = null, right = null, case = 0.20f + i * 0.08f, caseCharging = true))
}
val result = collectEstimate(estimator(emissions))
result["p1"].shouldNotBeNull().caseMinutesUntilCharged shouldBe 28
}
@Test
fun `a frozen case reading is never sampled`() = runTest(UnconfinedTestDispatcher()) {
// caseIsLive == false means the merged CASE value is a stale echo (pods undocked).
val emissions = (0 until 4).map { i ->
listOf(device("p1", left = null, right = null, case = 0.20f + i * 0.08f, caseCharging = true, caseLive = false))
}
collectEstimate(estimator(emissions)) shouldBe emptyMap()
}
@Test
fun `docked charging pods draw down the case into a transfer ratio`() = runTest(UnconfinedTestDispatcher()) {
// Unplugged case feeding both docked pods: pods gain 0.64 summed while the case drops
// 0.08 -> ratio 8.0. The window closes when the pods stop charging, then persists.
val docked = (0 until 5).map { i ->
listOf(
device(
"p1",
left = 0.20f + i * 0.08f, right = 0.20f + i * 0.08f, charging = true,
case = 0.50f - i * 0.02f, caseCharging = false,
)
)
}
val closed = listOf(
listOf(device("p1", left = 0.52f, right = 0.52f, case = 0.42f, caseCharging = false))
)
val emissions = docked + closed
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 { profile ->
val transfer = profile.caseTransfer
transfer != null && transfer.ratio > 7.9f && transfer.ratio < 8.1f &&
// The case never learns hourly drain rates.
profile.rates.keys.none { it.endsWith("/CASE") }
})
}
}
@Test
fun `a plugged-in case opens no transfer window`() = runTest(UnconfinedTestDispatcher()) {
// Case charging from cable while pods also charge — nothing here measures transfer.
val emissions = (0 until 5).map { i ->
listOf(
device(
"p1",
left = 0.20f + i * 0.08f, right = 0.20f + i * 0.08f, charging = true,
case = 0.80f, caseCharging = 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(exactly = 0) {
drainStore.save(any(), match { it.caseTransfer != null })
}
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -116,6 +116,34 @@ class BatteryHealthTest : BaseTest() {
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
}
@Test
fun `case health is the observed transfer ratio vs the nominal`() {
// Pro 2: nominal = (30 - 6) / 6 * 2 = 8.0 summed pod-fraction per case fraction.
// An observed 4.0 means the case delivers half its rated recharges -> 50%.
val profile = DrainProfile(
caseTransfer = DrainProfile.TransferRatio(ratio = 4f, updateCount = 3, updatedAt = Instant.EPOCH),
)
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
health.case shouldBe 50
health.left shouldBe null
}
@Test
fun `case health needs enough observed sessions`() {
val profile = DrainProfile(
caseTransfer = DrainProfile.TransferRatio(ratio = 4f, updateCount = 2, updatedAt = Instant.EPOCH),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `case health is capped at 100`() {
val profile = DrainProfile(
caseTransfer = DrainProfile.TransferRatio(ratio = 12f, updateCount = 3, updatedAt = Instant.EPOCH),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().case shouldBe 100
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
@@ -30,6 +30,7 @@ class DrainProfileSerializationTest : BaseTest() {
profile.chargeRates shouldBe emptyMap()
profile.chargeBands shouldBe emptyMap()
profile.listeningRates shouldBe emptyMap()
profile.caseTransfer shouldBe null
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
}
@@ -71,6 +72,11 @@ class DrainProfileSerializationTest : BaseTest() {
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
caseTransfer = DrainProfile.TransferRatio(
ratio = 6.5f,
updateCount = 4,
updatedAt = Instant.ofEpochMilli(1700000000000L),
),
)
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile
@@ -155,6 +155,22 @@ class ModelFeaturesTest : BaseTest() {
}
}
@Test
fun `with-case totals exist for cased models and exceed the single-charge rating`() {
PodModel.entries.forEach { model ->
val spec = model.batterySpec ?: return@forEach
withClue(model.name) {
if (model.features.hasCase) {
val withCase = spec.listeningHoursWithCase
val single = spec.listeningHoursAncOn ?: spec.listeningHoursAncOff
(withCase != null && single != null && withCase > single) shouldBe true
} else {
spec.listeningHoursWithCase shouldBe null
}
}
}
}
@Test
fun `every battery spec carries a plausible quick-charge rate`() {
// Derived from Apple's published quick-charge claims; must sit inside the band the live