mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
+31
-5
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+43
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user