ui(battery): Show charge ETA in the charging chip, make health per-pod

- The gauge line under the percentage now always shows the runtime estimate
  ("if used now") even while charging; the time-until-charged moved into the
  charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
  the info sheet, mirroring the serial rows) — single-pod listening habits or
  a replaced earbud make the sides genuinely diverge, and a combined figure
  would mask a failing pod
This commit is contained in:
darken
2026-07-02 10:35:56 +02:00
parent 96d2de1f51
commit 245972f2b6
12 changed files with 194 additions and 76 deletions
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
@@ -295,8 +296,18 @@ fun DeviceSettingsScreen(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = state.batteryHealthPercent?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
batteryHealth = state.batteryHealth?.let { health ->
BatteryHealthTexts(
left = health.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
right = health.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
headset = health.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
)
},
)
DeviceInfoCard(
@@ -171,9 +171,9 @@ class DeviceSettingsViewModel @Inject constructor(
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
// Health rides on the same learned data as the estimate — the per-device toggle
// governs both.
batteryHealthPercent = device
batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimatePercent(drainProfiles[profileId], it.model) },
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
)
}
}.asLiveState()
@@ -200,8 +200,8 @@ class DeviceSettingsViewModel @Inject constructor(
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
/** Derived battery health (1..100), or null when there isn't enough learned data. */
val batteryHealthPercent: Int? = null,
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
val batteryHealth: BatteryHealth.PerPod? = null,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -19,6 +19,8 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
)
internal data class DeviceInfoDetailLabels(
@@ -33,19 +35,43 @@ internal data class DeviceInfoDetailLabels(
val leftBonded: String,
val rightBonded: String,
val batteryHealth: String,
val leftBatteryHealth: String,
val rightBatteryHealth: String,
)
/** Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted. */
internal data class BatteryHealthTexts(
val left: String? = null,
val right: String? = null,
val headset: String? = null,
)
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
if (health == null) return emptyList()
return buildList {
when {
health.left != null && health.right != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
)
)
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
}
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
}
}
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
batteryHealth: String? = null,
batteryHealth: BatteryHealthTexts? = null,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
// Battery health is derived locally, so it's available (and shows the info button) even for
// BLE-only devices that never produce an AAP device-info response.
if (info == null) {
return batteryHealth?.let { listOf(DeviceDetailItem.Single(labels.batteryHealth, it)) } ?: emptyList()
}
if (info == null) return healthItems(batteryHealth, labels)
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
@@ -89,6 +115,6 @@ internal fun buildDeviceInfoDetailItems(
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
}
batteryHealth?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
addAll(healthItems(batteryHealth, labels))
}
}
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.overview.cards
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
@@ -240,9 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let {
formatEstimateText(context, it, isCharging = device.isLeftPodCharging == true)
},
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
PodGauge(
@@ -255,9 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let {
formatEstimateText(context, it, isCharging = device.isRightPodCharging == true)
},
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
}
@@ -313,6 +310,7 @@ private fun PodGauge(
showMicrophone: Boolean,
modifier: Modifier = Modifier,
timeRemaining: String? = null,
untilCharged: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -405,23 +403,11 @@ private fun PodGauge(
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label),
chargingDetail = untilCharged,
)
}
}
/**
* 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, 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
private fun CaseRow(
@@ -267,12 +267,10 @@ private fun ColumnScope.SinglePodsCardExpanded(
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset?.let {
formatEstimateText(context, it, isCharging = device.isHeadsetBeingCharged == true)
}
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = headsetEstimate,
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -296,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
AapPodState.ChargingState.CHARGING -> {
// Time-until-charged lives in the charging chip; the in-ring estimate
// always means runtime, so the two can't be confused.
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
?.let { formatBatteryDurationShort(context, it) }
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = untilCharged
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
?: stringResource(R.string.pods_charging_label),
)
}
else -> Unit
}
if (device.isBeingWorn == true) {
@@ -71,6 +71,7 @@ fun StatusChipRow(
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
chargingDetail: String? = null,
) {
FlowRow(
modifier = modifier.animateContentSize(),
@@ -82,9 +83,11 @@ fun StatusChipRow(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingOptimizedLabel,
)
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
// estimate line under the percentage always means runtime, so the two can't be confused.
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
)
else -> Unit
}
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
chargingDetail = "25m",
)
}
@@ -18,7 +18,8 @@ data class BatteryEstimate(
* bounded by the model's rated life
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
* or the final trickle phase). When set, the UI shows this instead of [minutesRemaining].
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
* the gauge line as the runtime projection.
*/
data class Pod(
val minutesRemaining: Int,
@@ -5,33 +5,49 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.math.roundToInt
/**
* Derives a rough battery-health percentage from learned drain rates vs the model's rated battery
* life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based proxy:
* a battery that only lasts 4.5h of a rated 6h reads as ~75%.
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
*
* The MEDIAN of the qualifying learned rates is used rather than the best or worst: sessions where
* the pods idled (in-ear, nothing playing) drain slower than the listening rating and would pull a
* "best" pick to a meaningless 100%, while call-heavy or cold sessions drain faster and would drag
* a "worst" pick into false doom. The median lands between both confounds. It remains an estimate —
* label it as such in the UI.
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Within a pod, the MEDIAN of its
* qualifying learned rates is used rather than the best or worst: sessions where the pod idled
* (in-ear, nothing playing) drain slower than the listening rating and would pull a "best" pick to a
* meaningless 100%, while call-heavy or cold sessions drain faster and would drag a "worst" pick
* into false doom. The median lands between both confounds. It remains an estimate — label it as
* such in the UI.
*/
object BatteryHealth {
/** A learned rate must have accumulated this many separate sessions before it counts. */
const val MIN_UPDATE_COUNT = 3
private val VALID_SLOTS = setOf("LEFT", "RIGHT", "HEADSET")
data class PerPod(
val left: Int? = null,
val right: Int? = null,
val headset: Int? = null,
) {
val hasAny: Boolean get() = left != null || right != null || headset != null
}
fun estimatePercent(profile: DrainProfile?, model: PodModel): Int? {
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
return PerPod(
left = slotPercent(profile, spec, "LEFT"),
right = slotPercent(profile, spec, "RIGHT"),
headset = slotPercent(profile, spec, "HEADSET"),
).takeIf { it.hasAny }
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.rates.mapNotNull { (key, rate) ->
// Keys must be exactly "<bucket>/<slot>" with a known slot — anything else is corrupted
// or future-format data and must not feed a health figure.
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
// future-format data and must not feed a health figure.
val parts = key.split('/')
if (parts.size != 2 || parts[1] !in VALID_SLOTS) return@mapNotNull null
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
+3 -2
View File
@@ -319,7 +319,6 @@
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="battery_time_until_charged_short">⚡ %1$s</string>
<string name="permission_post_notifications_label">Show notifications</string>
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
@@ -477,7 +476,9 @@
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
<string name="device_settings_info_battery_health_label">Battery health (estimated)</string>
<string name="device_settings_info_battery_health_label">Battery Health (estimated)</string>
<string name="device_settings_info_battery_health_left_label">Left Battery Health (est.)</string>
<string name="device_settings_info_battery_health_right_label">Right Battery Health (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_details_label">Device Details</string>
<string name="device_settings_info_details_action">Show device details</string>
@@ -14,6 +14,7 @@ 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.BatteryHealth
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
@@ -571,7 +572,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe 50
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
}
@Test
@@ -606,7 +607,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe null
vm.state.first().batteryHealth shouldBe null
}
@Test
@@ -21,6 +21,8 @@ class DeviceInfoDetailItemsTest : BaseTest() {
leftBonded = "Left Bonded",
rightBonded = "Right Bonded",
batteryHealth = "Battery Health",
leftBatteryHealth = "Left Battery Health",
rightBatteryHealth = "Right Battery Health",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
@@ -59,9 +61,17 @@ class DeviceInfoDetailItemsTest : BaseTest() {
@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)
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(left = "~85%", right = "~78%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~85%"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Battery Health", "~85%"),
end = DeviceDetailItem.Single("Right Battery Health", "~78%"),
),
)
}
@@ -70,14 +80,43 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
batteryHealth = "~72%",
batteryHealth = BatteryHealthTexts(left = "~72%", right = "~90%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
DeviceDetailItem.Single("Battery Health", "~72%"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Battery Health", "~72%"),
end = DeviceDetailItem.Single("Right Battery Health", "~90%"),
),
)
}
@Test
fun `single-sided battery health yields a Single row`() {
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(left = "~85%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Battery Health", "~85%"),
)
}
@Test
fun `headset battery health yields a Single row with the generic label`() {
val result = buildDeviceInfoDetailItems(
null,
labels,
batteryHealth = BatteryHealthTexts(headset = "~64%"),
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~64%"),
)
}
@@ -2,6 +2,7 @@ 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.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -21,28 +22,45 @@ class BatteryHealthTest : BaseTest() {
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
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@Test
fun `health is computed per pod`() {
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
// diverge — each pod gets its own figure instead of one masking the other.
val profile = DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
)
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
health.left shouldBe 50
health.right shouldBe 100
health.headset shouldBe null
}
@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
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left 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.
fun `health uses the median across a pod's learned rates`() {
// Three qualifying LEFT 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),
"ON/LEFT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@Test
@@ -50,19 +68,19 @@ class BatteryHealthTest : BaseTest() {
val profile = DrainProfile(
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(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()
BatteryHealth.estimate(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()
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -71,7 +89,7 @@ class BatteryHealthTest : BaseTest() {
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -87,7 +105,7 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
@@ -95,6 +113,15 @@ class BatteryHealthTest : BaseTest() {
// 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
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
val profile = DrainProfile(rates = mapOf("ON/HEADSET" to rate(0.1f)))
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
health.left shouldBe null
}
}