mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> = PodModel.entries
|
||||
.filter { predicate(it.features) }
|
||||
.toSet()
|
||||
|
||||
Reference in New Issue
Block a user