diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 1e7bdf5c..b773a648 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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( diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 4d4855d2..9cd442e7 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt index 40e95875..fd406431 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt @@ -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 { + 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 { // 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)) } } 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 464edb57..10262103 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 @@ -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( 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 0ff2847f..71414385 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,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) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt index 19965fff..b67f57fa 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt @@ -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", ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt index b7be278a..6034d471 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt index f0dae917..5e4d3f8e 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt @@ -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 "/" with a known slot — anything else is corrupted - // or future-format data and must not feed a health figure. + // Keys must be exactly "/" — 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15bf100c..737ae9c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -319,7 +319,6 @@ %1$dh %2$dm %1$dh %1$dm - ⚡ %1$s Show notifications "Allow CAPod to show notifications about your AirPods, e.g. their current status while connected." @@ -477,7 +476,9 @@ Right Pod Serial Left Bonded Right Bonded - Battery health (estimated) + Battery Health (estimated) + Left Battery Health (est.) + Right Battery Health (est.) ~%1$d%% Device Details Show device details diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 91857a1f..f0ae7f33 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt index 20d0e47f..3752c602 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt @@ -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%"), ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt index 478841e6..fd060623 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt @@ -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 } }