ui(battery): Show charge ETA in the charging chip, make health per-pod

- The gauge line under the percentage now always shows the runtime estimate
  ("if used now") even while charging; the time-until-charged moved into the
  charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
  the info sheet, mirroring the serial rows) — single-pod listening habits or
  a replaced earbud make the sides genuinely diverge, and a combined figure
  would mask a failing pod
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent ba3e253569
commit 5de5dee52f
12 changed files with 194 additions and 76 deletions
@@ -14,6 +14,7 @@ import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.BatteryHealth
import eu.darken.capod.monitor.core.battery.DrainProfile
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -571,7 +572,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe 50
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
}
@Test
@@ -606,7 +607,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe null
vm.state.first().batteryHealth shouldBe null
}
@Test
@@ -21,6 +21,8 @@ class DeviceInfoDetailItemsTest : BaseTest() {
leftBonded = "Left Bonded",
rightBonded = "Right Bonded",
batteryHealth = "Battery Health",
leftBatteryHealth = "Left Battery Health",
rightBatteryHealth = "Right Battery Health",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
@@ -59,9 +61,17 @@ class DeviceInfoDetailItemsTest : BaseTest() {
@Test
fun `battery health shows without AapDeviceInfo`() {
// BLE-only devices never produce an AAP info response but can still have learned health.
val result = buildDeviceInfoDetailItems(null, labels, batteryHealth = "~85%", formatDate = formatter)
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(left = "~85%", right = "~78%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~85%"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Battery Health", "~85%"),
end = DeviceDetailItem.Single("Right Battery Health", "~78%"),
),
)
}
@@ -70,14 +80,43 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
batteryHealth = "~72%",
batteryHealth = BatteryHealthTexts(left = "~72%", right = "~90%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
DeviceDetailItem.Single("Battery Health", "~72%"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Battery Health", "~72%"),
end = DeviceDetailItem.Single("Right Battery Health", "~90%"),
),
)
}
@Test
fun `single-sided battery health yields a Single row`() {
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(left = "~85%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Battery Health", "~85%"),
)
}
@Test
fun `headset battery health yields a Single row with the generic label`() {
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(headset = "~64%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~64%"),
)
}
@@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.PodModel
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
@@ -21,28 +22,45 @@ class BatteryHealthTest : BaseTest() {
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)))
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@Test
fun `health is computed per pod`() {
// 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(
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
)
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
health.left shouldBe 50
health.right shouldBe 100
health.headset shouldBe null
}
@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)))
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 100
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
}
@Test
fun `health uses the median across learned rates`() {
// Three qualifying entries at 100% / 50% / 25% equivalent -> the median (50%) wins, so a
// single gentle idle session can't inflate the figure and one hard session can't tank it.
fun `health uses the median across a pod's learned rates`() {
// Three qualifying LEFT entries at 100% / 50% / 25% equivalent -> the median (50%) wins,
// so a single gentle idle session can't inflate the figure and one hard session can't
// tank it.
val profile = DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 6f),
"UNKNOWN/RIGHT" to rate(1f / 3f),
"ON/LEFT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@Test
@@ -50,19 +68,19 @@ class BatteryHealthTest : BaseTest() {
val profile = DrainProfile(
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
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)))
BatteryHealth.estimatePercent(profile, PodModel.UNKNOWN).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
}
@Test
fun `no profile or no qualifying rates yields null`() {
BatteryHealth.estimatePercent(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimatePercent(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -71,7 +89,7 @@ class BatteryHealthTest : BaseTest() {
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -87,7 +105,7 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -95,6 +113,15 @@ class BatteryHealthTest : BaseTest() {
// 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)))
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_GEN4_ANC) shouldBe 50
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 health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
health.left shouldBe null
}
}