From ba3e253569f9eb7918d89d25c68d3da997df1564 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 10:17:19 +0200 Subject: [PATCH] fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - While charging, the gauge showed the bare runtime projection when no charge rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute charge. The line now shows the "until charged" ETA or nothing - Seed the charge rate from Apple's published quick-charge claims ("5 minutes in the case provides around 1 hour of listening"), normalized against the rated listening hours, so the ETA is present from the very first charge; a live fit still takes over within minutes --- .../main/ui/overview/cards/DualPodsCard.kt | 23 ++++++++----- .../main/ui/overview/cards/SinglePodsCard.kt | 6 ++-- .../monitor/core/battery/BatteryEstimator.kt | 8 ++++- .../darken/capod/pods/core/apple/PodModel.kt | 33 ++++++++++++------- .../core/battery/BatteryEstimatorTest.kt | 10 ++++++ .../pods/core/apple/ModelFeaturesTest.kt | 13 ++++++++ 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 24e95d13..464edb57 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -240,7 +240,9 @@ private fun ColumnScope.DualPodsCardExpanded( isMicrophone = device.isLeftPodMicrophone ?: false, showMicrophone = device.hasDualMicrophone, modifier = Modifier.weight(1f), - timeRemaining = batteryEstimate?.left?.let { formatEstimateText(context, it) }, + timeRemaining = batteryEstimate?.left?.let { + formatEstimateText(context, it, isCharging = device.isLeftPodCharging == true) + }, ) PodGauge( @@ -253,7 +255,9 @@ private fun ColumnScope.DualPodsCardExpanded( isMicrophone = device.isRightPodMicrophone ?: false, showMicrophone = device.hasDualMicrophone, modifier = Modifier.weight(1f), - timeRemaining = batteryEstimate?.right?.let { formatEstimateText(context, it) }, + timeRemaining = batteryEstimate?.right?.let { + formatEstimateText(context, it, isCharging = device.isRightPodCharging == true) + }, ) } @@ -406,14 +410,17 @@ private fun PodGauge( } /** - * The gauge's small estimate line: while charging with a usable rate, the time until full - * (language-neutral "⚡ 25m"); otherwise the usual time-remaining ("2h 15m"). Shared with + * The gauge's small estimate line: while charging, the time until full (language-neutral "⚡ 25m") + * or NOTHING — a bare runtime number next to a charging chip ("1% · 4m") inevitably reads as a + * four-minute charge. The runtime estimate only shows while not charging. Shared with * [SinglePodsCard] (same package). */ -internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod): String = - pod.minutesUntilCharged - ?.let { context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, it)) } - ?: formatBatteryDurationShort(context, pod.minutesRemaining) +internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod, isCharging: Boolean): String? = when { + pod.minutesUntilCharged != null -> + context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, pod.minutesUntilCharged)) + isCharging -> null + else -> formatBatteryDurationShort(context, pod.minutesRemaining) +} @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 6a17447d..0ff2847f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -267,10 +267,12 @@ private fun ColumnScope.SinglePodsCardExpanded( MaterialTheme.colorScheme.onSurfaceVariant }, ) - val headsetEstimate = batteryEstimate?.headset + val headsetEstimate = batteryEstimate?.headset?.let { + formatEstimateText(context, it, isCharging = device.isHeadsetBeingCharged == true) + } if (headsetEstimate != null) { Text( - text = formatEstimateText(context, headsetEstimate), + text = headsetEstimate, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt index 2a9ccb0a..8d3ca667 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt @@ -348,7 +348,13 @@ class BatteryEstimator @Inject constructor( val live = if (history.direction == SlotHistory.Direction.CHARGE) { DrainModel.chargeSlopeFractionPerHour(history.toList()) } else null - val rate = live ?: learnedChargeRate(profileId, device, slot) ?: return null + // Rate preference mirrors the drain side: measured, then learned, then Apple's published + // quick-charge claim ("5 minutes in the case = ~1 hour of listening") — so an ETA exists + // even on the very first charge. + val rate = live + ?: learnedChargeRate(profileId, device, slot) + ?: device.model.batterySpec?.chargeFractionPerHour + ?: return null val lastRise = tracker.lastRiseMs[slot] ?: return null val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index f71d71b9..c2116316 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -27,7 +27,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), modelNumbers = setOf("A1523", "A1722"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen1_left, rightPodIconRes = R.drawable.device_airpods_gen1_right, @@ -45,7 +45,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), modelNumbers = setOf("A2031", "A2032"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen1_left, rightPodIconRes = R.drawable.device_airpods_gen1_right, @@ -67,7 +67,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 6f), + batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f), modelNumbers = setOf("A2564", "A2565"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, rightPodIconRes = R.drawable.device_airpods_gen3_right, @@ -90,7 +90,7 @@ enum class PodModel( hasEarDetectionToggle = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, rightPodIconRes = R.drawable.device_airpods_gen3_right, @@ -122,7 +122,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f), + batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones leftPodIconRes = R.drawable.device_airpods_gen4anc_left, rightPodIconRes = R.drawable.device_airpods_gen4anc_right, @@ -148,7 +148,7 @@ enum class PodModel( hasListeningModeCycle = true, hasAllowOffOption = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f), + batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), modelNumbers = setOf("A2083", "A2084"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -182,7 +182,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 6f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -216,7 +216,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 6f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -251,7 +251,7 @@ enum class PodModel( hasSleepDetection = true, hasDynamicEndOfCharge = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 8f), + batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -272,7 +272,7 @@ enum class PodModel( hasListeningModeCycle = true, hasAllowOffOption = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 20f), + batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f), modelNumbers = setOf("A2096"), // headphones ), @@ -290,7 +290,7 @@ enum class PodModel( hasListeningModeCycle = true, hasAllowOffOption = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 20f), + batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f), modelNumbers = setOf("A3184"), // headphones ), @@ -312,7 +312,7 @@ enum class PodModel( hasListeningModeCycle = true, hasAllowOffOption = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 20f), + batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f), modelNumbers = setOf("A3454"), // headphones ), @@ -606,5 +606,14 @@ enum class PodModel( val listeningHoursAncOn: Float? = null, /** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */ val listeningHoursAncOff: Float? = null, + /** + * Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's + * published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of + * listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two + * listening ratings it's normalized against the LONGER one, so the seeded "until charged" + * never under-promises. Seeds the charge ETA on the very first charge, before any charge + * rate has been measured; a live fit takes over within minutes. + */ + val chargeFractionPerHour: Float? = null, ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt index 3aa5ee20..f9336be1 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt @@ -267,6 +267,16 @@ class BatteryEstimatorTest : BaseTest() { left.minutesUntilCharged shouldBe 28 } + @Test + fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) { + // Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim + // (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min. + val result = collectEstimate( + estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2)))) + ) + result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15 + } + @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. diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt index a226207c..83eeba80 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt @@ -155,6 +155,19 @@ class ModelFeaturesTest : BaseTest() { } } + @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 + // fit is validated against, or the seed could never be corrected by a measurement. + PodModel.entries.forEach { model -> + val spec = model.batterySpec ?: return@forEach + withClue(model.name) { + val rate = spec.chargeFractionPerHour + (rate != null && rate in 0.25f..4.0f) shouldBe true + } + } + } + private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set = PodModel.entries .filter { predicate(it.features) } .toSet()