mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
+1
@@ -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%.
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user