mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
+3
-2
@@ -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
|
||||
|
||||
+43
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user