mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 18:56:11 -04:00
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:
+74
@@ -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()
|
||||
|
||||
+40
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user