feat(battery): Add time-until-charged and derived battery health

- While a pod charges, fit its rising level and show the time until full in the
  gauge instead of the runtime estimate; learned charge rates are persisted per
  slot so the ETA appears immediately on later charges
- Suppress the charge ETA during Optimized Battery Charging holds, the final
  trickle phase, and whenever the level stalls longer than one visible step
  should take (granularity-aware: 1% AAP steps vs 10% BLE steps)
- Clear a slot's fit window when its readings switch between AAP and BLE — the
  granularity jump would otherwise read as a fake level step
- Derive a battery-health percentage (median of accumulated drain rates vs the
  model's rated life) and show it in the device info sheet; the info button now
  also appears for BLE-only devices once health data exists
- Tag learned rates with the model they came from so re-pointing a profile at
  different hardware starts learning fresh instead of inheriting foreign rates
- Track how many sessions blended into each learned rate and require three
  before a health figure is shown
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent aae8ad62bd
commit 4488af0ab7
17 changed files with 896 additions and 64 deletions
@@ -12,12 +12,16 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
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.DrainProfile
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.kotest.matchers.shouldBe
@@ -64,6 +68,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var batteryEstimator: BatteryEstimator
private lateinit var drainStore: BatteryDrainStore
private lateinit var drainProfilesFlow: MutableStateFlow<Map<ProfileId, DrainProfile>>
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
@@ -119,6 +125,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { profiles } returns profilesFlow
}
batteryEstimator = mockk(relaxed = true)
drainProfilesFlow = MutableStateFlow(emptyMap())
drainStore = mockk<BatteryDrainStore>().also {
every { it.profiles } returns drainProfilesFlow
}
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
@@ -144,6 +154,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
batteryEstimator = batteryEstimator,
drainStore = drainStore,
monitorModeResolver = monitorModeResolver,
nudgeCapabilityStore = nudgeCapabilityStore,
timeSource = timeSource,
@@ -535,6 +546,69 @@ class DeviceSettingsViewModelTest : BaseTest() {
vm.state.first().batteryEstimateEnabled shouldBe false
}
@Test
fun `state derives battery health from learned rates`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
// Rated 6h, learned 3h of runtime (0.333/hr) -> ~50% health.
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = 3,
updatedAt = java.time.Instant.EPOCH,
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe 50
}
@Test
fun `battery health hides when the estimate is disabled for the device`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = 3,
updatedAt = java.time.Instant.EPOCH,
)
),
)
)
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe null
}
@Test
fun `setBatteryEstimateEnabled updates the profile`() = runVmTest {
val vm = createViewModel()
@@ -20,6 +20,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
rightSerial = "Right Pod Serial",
leftBonded = "Left Bonded",
rightBonded = "Right Bonded",
batteryHealth = "Battery Health",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
@@ -52,7 +53,32 @@ class DeviceInfoDetailItemsTest : BaseTest() {
@Test
fun `null AapDeviceInfo yields empty list`() {
buildDeviceInfoDetailItems(null, labels, formatter) shouldBe emptyList()
buildDeviceInfoDetailItems(null, labels, formatDate = formatter) shouldBe emptyList()
}
@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)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~85%"),
)
}
@Test
fun `battery health is appended after the info rows`() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
batteryHealth = "~72%",
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
DeviceDetailItem.Single("Battery Health", "~72%"),
)
}
@Test
@@ -60,7 +86,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -79,7 +105,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
firmwareVersion = "7A305",
),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -100,7 +126,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
marketingVersion = "8454768",
),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -116,7 +142,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", marketingVersion = "8454768"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
@@ -129,7 +155,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", firmwareVersionPending = " "),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
@@ -141,7 +167,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL", rightEarbudSerial = "RRR"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -156,7 +182,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Pod Serial", "LLL"),
@@ -168,7 +194,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudSerial = "RRR"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Pod Serial", "RRR"),
@@ -181,7 +207,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = sameSecond, rightEarbudFirstPaired = sameSecond),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -198,7 +224,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = left, rightEarbudFirstPaired = right),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -213,7 +239,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Bonded", "fmt:1697480211"),
@@ -225,7 +251,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Bonded", "fmt:1697480211"),
@@ -241,7 +267,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
rightEarbudFirstPaired = null,
),
labels,
formatter,
formatDate = formatter,
)
result.none { it is DeviceDetailItem.Paired } shouldBe true
result.none {
@@ -33,10 +33,15 @@ class BatteryEstimatorTest : BaseTest() {
left: Float?,
right: Float?,
charging: Boolean = false,
optimized: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
): PodDevice {
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
charging -> ChargingState.CHARGING
else -> ChargingState.NOT_CHARGING
}
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))
@@ -251,6 +256,143 @@ class BatteryEstimatorTest : BaseTest() {
collectEstimate(estimator(emissions)) shouldBe emptyMap()
}
@Test
fun `a rising charge yields a live time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// 2%/min while docked -> 1.2 fraction/hr -> at 44% that's (1 - 0.44) / 1.2 * 60 == 28 min.
val emissions = (0 until 4).map { i ->
val level = 0.20f + i * 0.08f
listOf(device("p1", left = level, right = level, charging = true, model = PodModel.AIRPODS_PRO2))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.minutesUntilCharged shouldBe 28
}
@Test
fun `a stored charge rate seeds time-until-charged immediately`() = runTest(UnconfinedTestDispatcher()) {
// First charging emission, no live fit possible yet -> the persisted rate answers at once.
// 50% missing at 1.2/hr == 25 min.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 25
}
@Test
fun `an optimized-charging hold suppresses time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// CHARGING_OPTIMIZED parks the level below full — an ETA would mislead, but the runtime
// projection stays visible.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val result = collectEstimate(
estimator(
emissions = listOf(
listOf(device("p1", left = 0.80f, right = 0.80f, charging = true, optimized = true, model = PodModel.AIRPODS_PRO2))
),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.minutesUntilCharged shouldBe null
left.source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `a stalled charge suppresses time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// Level stops rising while still flagged charging (unreported hold / trickle): once the
// silence outlasts the stall threshold the frozen ETA is dropped.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val emissions = listOf(
listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2)),
)
val result = collectEstimate(
estimator(emissions, stored = stored, clockMs = listOf(0L, 11 * 60_000L)),
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe null
}
@Test
fun `a discharging pod has no charge estimate`() = runTest(UnconfinedTestDispatcher()) {
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions, stored = stored))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LIVE
left.minutesUntilCharged shouldBe null
}
@Test
fun `charge rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val emissions = (0 until 4).map { i ->
val level = 0.20f + i * 0.08f
listOf(device("p1", left = level, right = level, charging = true, model = PodModel.AIRPODS_PRO2))
}
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 estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
estimator.monitor().collect {}
coVerify {
drainStore.save("p1", match { it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") })
}
}
@Test
fun `undocking does not leak charge samples into the drain fit`() = runTest(UnconfinedTestDispatcher()) {
// A charge session builds a rising window; the moment the pods leave the case the window
// must flip to drain from scratch — a fit across the rising samples would be garbage.
val emissions = listOf(
listOf(device("p1", left = 0.20f, right = 0.20f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.28f, right = 0.28f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.36f, right = 0.36f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.36f, right = 0.36f, model = PodModel.AIRPODS_PRO2)), // undocked
)
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
// One drain sample only -> no live fit, nothing learned -> the rating answers.
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesUntilCharged shouldBe null
}
@Test
fun `learned rates from different hardware are ignored`() = runTest(UnconfinedTestDispatcher()) {
// The profile was re-pointed from an AirPods Pro to a Pro 2 — its old rates don't describe
// this device, so the estimate falls back to the current model's rating.
val stored = mapOf(
"p1" to DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f)),
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -0,0 +1,100 @@
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.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class BatteryHealthTest : BaseTest() {
private fun rate(fractionPerHour: Float, updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT) =
DrainProfile.LearnedRate(
fractionPerHour = fractionPerHour,
sampleCount = 10,
updateCount = updateCount,
updatedAt = Instant.EPOCH,
)
@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)))
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
}
@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
}
@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.
val profile = DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 6f),
"UNKNOWN/RIGHT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
}
@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))
)
BatteryHealth.estimatePercent(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()
}
@Test
fun `no profile or no qualifying rates yields null`() {
BatteryHealth.estimatePercent(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimatePercent(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `rates learned on different hardware are ignored`() {
val profile = DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `malformed bucket keys and broken rates are skipped`() {
val profile = DrainProfile(
rates = 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
"UNKNOWN/CASE" to rate(1f / 3f), // not an estimated slot
"UNKNOWN/LEFT/EXTRA" to rate(1f / 3f), // extra path component
"UNKNOWN/LEFT" to rate(0f), // non-positive rate
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
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)))
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_GEN4_ANC) shouldBe 50
}
}
@@ -114,4 +114,76 @@ class DrainModelTest : BaseTest() {
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.90f, 0.003f, count = 8))!!
(rate.isFinite() && rate > 0f) shouldBe true
}
/** Samples charging at a constant rate, [perMinute] fraction gained per minute. */
private fun chargingSamples(
start: Float,
perMinute: Float,
count: Int,
stepMinutes: Long = 4,
): List<DrainSample> = (0 until count).map { i ->
DrainSample(
atElapsedMs = i * stepMinutes * 60_000L,
fraction = start + perMinute * (i * stepMinutes),
)
}
@Test
fun `charge slope recovers a constant charge rate in fraction per hour`() {
// 2% per minute == 120% per hour == 1.2 fraction/hour (a ~50 min full charge).
val rate = DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 4))
rate.shouldNotBeNull()
rate shouldBe (1.2f plusOrMinus 0.05f)
}
@Test
fun `a draining pod is not a charge`() {
DrainModel.chargeSlopeFractionPerHour(drainingSamples(0.80f, 0.02f, count = 4)).shouldBeNull()
}
@Test
fun `a negligible rise is rejected`() {
// Long window but total rise below MIN_TOTAL_RISE.
val samples = (0 until 4).map { DrainSample(it * 5 * 60_000L, 0.50f + it * 0.005f) }
DrainModel.chargeSlopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `an implausibly slow charge is rejected`() {
// ~6%/hr would mean a 16-hour charge — outside CHARGE_RATE_MIN.
val samples = (0 until 4).map { DrainSample(it * 20 * 60_000L, 0.30f + it * 0.02f) }
DrainModel.chargeSlopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `charge fits need fewer samples than drain fits`() {
// 3 samples is enough for a charge fit (BLE's 10% steps make more expensive)...
DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 3)).shouldNotBeNull()
// ...but not fewer.
DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 2)).shouldBeNull()
}
@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
}
@Test
fun `minutesUntilFull suppresses the trickle zone`() {
DrainModel.minutesUntilFull(0.98f, 1.2f).shouldBeNull()
}
@Test
fun `minutesUntilFull rejects a non-positive rate`() {
DrainModel.minutesUntilFull(0.60f, 0f).shouldBeNull()
}
@Test
fun `charge stall threshold is granularity aware`() {
// AAP's 1% step at 1.2/hr passes in ~30s -> the 10-minute floor applies.
DrainModel.chargeStallThresholdMs(1.2f, 0.01f) shouldBe DrainModel.CHARGE_STALL_FLOOR_MS
// BLE's 10% step at a slow 0.3/hr takes 20 min -> the threshold must exceed it (30 min).
DrainModel.chargeStallThresholdMs(0.3f, 0.10f) shouldBe 30 * 60_000L
}
}
@@ -0,0 +1,58 @@
package eu.darken.capod.monitor.core.battery
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class DrainProfileSerializationTest : BaseTest() {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `profiles stored before charge rates and the model tag decode with defaults`() {
val legacyJson = """
{
"rates": {
"UNKNOWN/LEFT": {
"fractionPerHour": 0.15,
"sampleCount": 12,
"updatedAt": 1700000000000
}
}
}
""".trimIndent()
val profile = json.decodeFromString<DrainProfile>(legacyJson)
profile.model shouldBe null
profile.chargeRates shouldBe emptyMap()
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
}
@Test
fun `full profile round-trips`() {
val profile = DrainProfile(
model = "AIRPODS_PRO2",
rates = mapOf(
"ON/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 0.21f,
sampleCount = 9,
updateCount = 4,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
chargeRates = mapOf(
"LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1.3f,
sampleCount = 5,
updateCount = 2,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
)
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile
}
}