mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(battery): Warn when a pod's listening time drops by half
The per-pod listening-time estimate was only visible behind the info icon on the device settings header card. A pod that reaches half its rated listening hours or less now raises a banner on the device settings screen, tapping it opens the same detail sheet. A displayed number needs less backing than one that raises a warning, so a reading is only promoted into the banner when its slot has accumulated at least 8 listening sessions across its qualifying rates and the newest of those rates is at most 60 days old. The banner reads the already-gated state field, so the per-profile battery estimate toggle suppresses it too. The detail sheet's visibility moves out of DeviceInfoCard so both the info icon and the banner can open it.
This commit is contained in:
@@ -45,6 +45,7 @@ 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.BatteryRuntimeWarningBanner
|
||||
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
|
||||
@@ -240,6 +241,9 @@ fun DeviceSettingsScreen(
|
||||
val features = device?.model?.features
|
||||
val enabled = device?.isAapReady == true
|
||||
val isPro = state.isPro
|
||||
// Hoisted out of DeviceInfoCard: the runtime warning banner opens the same detail sheet as the
|
||||
// card's info icon.
|
||||
var showDeviceDetails by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -306,13 +310,13 @@ fun DeviceSettingsScreen(
|
||||
batteryHealth = when {
|
||||
state.batteryHealth != null -> BatteryHealthTexts(
|
||||
left = state.batteryHealth.left?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
|
||||
},
|
||||
right = state.batteryHealth.right?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
|
||||
},
|
||||
headset = state.batteryHealth.headset?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
|
||||
},
|
||||
)
|
||||
state.batteryHealthPending -> BatteryHealthTexts(
|
||||
@@ -331,6 +335,8 @@ fun DeviceSettingsScreen(
|
||||
detailItems = detailItems,
|
||||
canRename = device.isAapReady,
|
||||
onRename = onDeviceNameChange,
|
||||
showDetails = showDeviceDetails,
|
||||
onShowDetailsChange = { showDeviceDetails = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +351,19 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Listening time has dropped far below the model's rating — details live in the sheet
|
||||
val runtimeWarning = state.batteryRuntimeWarning
|
||||
if (device != null && device.hasSelectedPairedDevice && runtimeWarning != null) {
|
||||
item("battery_runtime_warning") {
|
||||
BatteryRuntimeWarningBanner(
|
||||
slot = runtimeWarning.slot,
|
||||
percent = runtimeWarning.reading.percent,
|
||||
onClick = { showDeviceDetails = true },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Not nearby — no live BLE; settings require the device to be present
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble == null && !state.isClassicallyConnected
|
||||
|
||||
+18
-10
@@ -160,9 +160,15 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
null
|
||||
}
|
||||
}
|
||||
val now = timeSource.now()
|
||||
// The runtime figure rides on the same learned data as the estimate — the per-device
|
||||
// toggle governs both.
|
||||
val batteryHealth = device
|
||||
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model, now) }
|
||||
State(
|
||||
device = device,
|
||||
now = timeSource.now(),
|
||||
now = now,
|
||||
isPro = upgrade.isPro,
|
||||
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
|
||||
isForceConnecting = forcing,
|
||||
@@ -174,12 +180,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
||||
} == true,
|
||||
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||
// governs both.
|
||||
batteryHealth = device
|
||||
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||
// A model with a rating WILL eventually produce a health figure — surface the
|
||||
batteryHealth = batteryHealth,
|
||||
batteryRuntimeWarning = batteryHealth?.lowestPromotable
|
||||
?.takeIf { it.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT },
|
||||
// A model with a rating WILL eventually produce a runtime figure — surface the
|
||||
// feature as "still determining" until the listening sessions accumulate. Without
|
||||
// a paired device the listening gate can never open, so no promise is made.
|
||||
batteryHealthPending = device != null &&
|
||||
@@ -212,10 +216,14 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
val systemBluetoothName: String? = null,
|
||||
val hasCustomLongPressStemAction: Boolean = false,
|
||||
val batteryEstimateEnabled: Boolean = true,
|
||||
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||
/** Derived per-pod share of the rated listening time (1..100 each), or null when there
|
||||
* isn't enough learned data. */
|
||||
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||
/** True when health CAN be derived for this device (rated model, feature on) — shows the
|
||||
* "still determining" placeholder while [batteryHealth] is null. */
|
||||
/** The worst pod whose runtime has dropped to [BatteryHealth.LOW_RUNTIME_PERCENT] or below
|
||||
* on enough recent evidence to warn about — drives the warning banner. */
|
||||
val batteryRuntimeWarning: BatteryHealth.SlotReading? = null,
|
||||
/** True when a runtime figure CAN be derived for this device (rated model, feature on) —
|
||||
* shows the "still determining" placeholder while [batteryHealth] is null. */
|
||||
val batteryHealthPending: Boolean = false,
|
||||
) {
|
||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.monitor.core.battery.BatteryHealth
|
||||
|
||||
@Composable
|
||||
internal fun BatteryRuntimeWarningBanner(
|
||||
slot: BatteryHealth.Slot,
|
||||
percent: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val podLabel = when (slot) {
|
||||
BatteryHealth.Slot.LEFT -> stringResource(R.string.pods_dual_left_label)
|
||||
BatteryHealth.Slot.RIGHT -> stringResource(R.string.pods_dual_right_label)
|
||||
BatteryHealth.Slot.HEADSET -> stringResource(R.string.device_settings_battery_runtime_warning_headset_label)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics(mergeDescendants = true) {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_battery_runtime_warning_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.device_settings_battery_runtime_warning_description,
|
||||
podLabel,
|
||||
percent,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryRuntimeWarningBannerEarbudPreview() = PreviewWrapper {
|
||||
BatteryRuntimeWarningBanner(
|
||||
slot = BatteryHealth.Slot.LEFT,
|
||||
percent = 42,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryRuntimeWarningBannerHeadsetPreview() = PreviewWrapper {
|
||||
BatteryRuntimeWarningBanner(
|
||||
slot = BatteryHealth.Slot.HEADSET,
|
||||
percent = 50,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
@@ -53,12 +53,13 @@ internal fun DeviceInfoCard(
|
||||
detailItems: List<DeviceDetailItem> = emptyList(),
|
||||
canRename: Boolean = false,
|
||||
onRename: (String) -> Unit = {},
|
||||
showDetails: Boolean = false,
|
||||
onShowDetailsChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(detailItems) {
|
||||
if (detailItems.isEmpty()) showBottomSheet = false
|
||||
if (detailItems.isEmpty()) onShowDetailsChange(false)
|
||||
}
|
||||
|
||||
if (showRenameDialog && deviceInfo != null) {
|
||||
@@ -72,10 +73,10 @@ internal fun DeviceInfoCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (showBottomSheet && detailItems.isNotEmpty()) {
|
||||
if (showDetails && detailItems.isNotEmpty()) {
|
||||
DeviceInfoBottomSheet(
|
||||
items = detailItems,
|
||||
onDismiss = { showBottomSheet = false },
|
||||
onDismiss = { onShowDetailsChange(false) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ internal fun DeviceInfoCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (showInfoIconInModelRow) {
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
IconButton(onClick = { onShowDetailsChange(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Info,
|
||||
contentDescription = stringResource(R.string.device_settings_info_details_action),
|
||||
@@ -144,7 +145,7 @@ internal fun DeviceInfoCard(
|
||||
} else null,
|
||||
)
|
||||
if (showInfoIconInNameRow) {
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
IconButton(onClick = { onShowDetailsChange(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Info,
|
||||
contentDescription = stringResource(R.string.device_settings_info_details_action),
|
||||
@@ -159,7 +160,7 @@ internal fun DeviceInfoCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
IconButton(onClick = { onShowDetailsChange(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Info,
|
||||
contentDescription = stringResource(R.string.device_settings_info_details_action),
|
||||
|
||||
@@ -2,72 +2,129 @@ package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 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%.
|
||||
* Derives how much of a model's rated listening time a pod still delivers, from learned drain rates
|
||||
* vs that rating: a pod that only lasts 4.5h of a rated 6h reads as ~75%. Nothing on the wire
|
||||
* exposes Apple's real health/cycle data, and a short runtime can just as well come from loud
|
||||
* volume, cold weather or a hungry codec — so this is a runtime figure, not a cell-health verdict.
|
||||
*
|
||||
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||
* It 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. Only [DrainProfile.listeningRates]
|
||||
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
|
||||
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
|
||||
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
|
||||
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
|
||||
* the UI.
|
||||
* ratings are listening figures — general rates include idle wear and would flatter the result.
|
||||
* Within a pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping
|
||||
* remaining confounds (volume, calls, cold) in either direction. It remains an estimate — label it
|
||||
* as such in the UI.
|
||||
*
|
||||
* A figure that is merely displayed needs less backing than one that raises a warning, hence
|
||||
* [Reading.isPromotable].
|
||||
*/
|
||||
object BatteryHealth {
|
||||
|
||||
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
||||
const val MIN_UPDATE_COUNT = 3
|
||||
|
||||
/**
|
||||
* Sessions a slot must have accumulated across all its qualifying rates before its figure may
|
||||
* be promoted into a warning. `updateCount` rises once per listening session, so this is
|
||||
* roughly a week of real use.
|
||||
*/
|
||||
const val MIN_PROMOTE_UPDATE_COUNT = 8
|
||||
|
||||
/** A slot's newest qualifying rate may be at most this old, or its figure is too stale to warn on. */
|
||||
val MAX_PROMOTE_AGE: Duration = Duration.ofDays(60)
|
||||
|
||||
/** At or below this percentage of the rated listening time, a promotable reading warrants a warning. */
|
||||
const val LOW_RUNTIME_PERCENT = 50
|
||||
|
||||
enum class Slot { LEFT, RIGHT, HEADSET }
|
||||
|
||||
/**
|
||||
* @param percent share of the rated listening time this pod still reaches, 1..100
|
||||
* @param isPromotable whether enough recent evidence backs [percent] to act on it
|
||||
*/
|
||||
data class Reading(
|
||||
val percent: Int,
|
||||
val isPromotable: Boolean,
|
||||
)
|
||||
|
||||
data class SlotReading(
|
||||
val slot: Slot,
|
||||
val reading: Reading,
|
||||
)
|
||||
|
||||
data class PerPod(
|
||||
val left: Int? = null,
|
||||
val right: Int? = null,
|
||||
val headset: Int? = null,
|
||||
val left: Reading? = null,
|
||||
val right: Reading? = null,
|
||||
val headset: Reading? = null,
|
||||
) {
|
||||
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||
|
||||
/** The worst reading that rests on enough recent evidence to act on, or null if none does. */
|
||||
val lowestPromotable: SlotReading?
|
||||
get() = listOfNotNull(
|
||||
left?.let { SlotReading(Slot.LEFT, it) },
|
||||
right?.let { SlotReading(Slot.RIGHT, it) },
|
||||
headset?.let { SlotReading(Slot.HEADSET, it) },
|
||||
)
|
||||
.filter { it.reading.isPromotable }
|
||||
.minByOrNull { it.reading.percent }
|
||||
}
|
||||
|
||||
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
|
||||
fun estimate(profile: DrainProfile?, model: PodModel, now: Instant): 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"),
|
||||
left = slotReading(profile, spec, Slot.LEFT, now),
|
||||
right = slotReading(profile, spec, Slot.RIGHT, now),
|
||||
headset = slotReading(profile, spec, Slot.HEADSET, now),
|
||||
).takeIf { it.hasAny }
|
||||
}
|
||||
|
||||
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
|
||||
private fun slotReading(
|
||||
profile: DrainProfile,
|
||||
spec: PodModel.BatterySpec,
|
||||
slot: Slot,
|
||||
now: Instant,
|
||||
): Reading? {
|
||||
val qualifying = profile.listeningRates.mapNotNull { (key, rate) ->
|
||||
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||
// future-format data and must not feed a health figure.
|
||||
// future-format data and must not feed a runtime figure.
|
||||
val parts = key.split('/')
|
||||
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
|
||||
if (parts.size != 2 || parts[1] != slot.name) 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
|
||||
(1f / specHours) / rate.fractionPerHour
|
||||
(1f / specHours) / rate.fractionPerHour to rate
|
||||
}
|
||||
if (ratios.isEmpty()) return null
|
||||
if (qualifying.isEmpty()) return null
|
||||
|
||||
val sorted = ratios.sorted()
|
||||
val sorted = qualifying.map { it.first }.sorted()
|
||||
val median = if (sorted.size % 2 == 1) {
|
||||
sorted[sorted.size / 2]
|
||||
} else {
|
||||
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
|
||||
}
|
||||
return (median * 100f).roundToInt().coerceIn(1, 100)
|
||||
|
||||
val sessions = qualifying.sumOf { it.second.updateCount }
|
||||
val newest = qualifying.maxOf { it.second.updatedAt }
|
||||
return Reading(
|
||||
percent = (median * 100f).roundToInt().coerceIn(1, 100),
|
||||
isPromotable = sessions >= MIN_PROMOTE_UPDATE_COUNT &&
|
||||
Duration.between(newest, now) <= MAX_PROMOTE_AGE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
|
||||
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
|
||||
* the shorter one would systematically flatter health, the longer one would slander it.
|
||||
* the shorter one would systematically flatter the figure, the longer one would slander it.
|
||||
* Malformed or unrecognized bucket keys yield null (entry is skipped).
|
||||
*/
|
||||
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
|
||||
|
||||
@@ -498,6 +498,9 @@
|
||||
<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_battery_health_pending">Still determining, check back later</string>
|
||||
<string name="device_settings_battery_runtime_warning_title">Listening time is much shorter than rated</string>
|
||||
<string name="device_settings_battery_runtime_warning_description">%1$s: about %2$d%% of the rated listening time. Tap for details.</string>
|
||||
<string name="device_settings_battery_runtime_warning_headset_label">These headphones</string>
|
||||
<string name="device_settings_info_details_label">Device Details</string>
|
||||
<string name="device_settings_info_details_action">Show device details</string>
|
||||
<string name="device_settings_info_status_label">Status</string>
|
||||
|
||||
+97
-1
@@ -576,7 +576,103 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
|
||||
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
|
||||
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(
|
||||
left = BatteryHealth.Reading(percent = 50, isPromotable = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low runtime on recent evidence raises a warning`() = runVmTest {
|
||||
val device = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testAddress
|
||||
every { it.model } returns PodModel.AIRPODS_PRO2
|
||||
}
|
||||
devicesFlow.value = listOf(device)
|
||||
drainProfilesFlow.value = mapOf(
|
||||
testAddress to DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO2.name,
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 1f / 3f,
|
||||
sampleCount = 10,
|
||||
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
|
||||
updatedAt = timeSource.now(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
|
||||
vm.state.first().batteryRuntimeWarning shouldBe BatteryHealth.SlotReading(
|
||||
slot = BatteryHealth.Slot.LEFT,
|
||||
reading = BatteryHealth.Reading(percent = 50, isPromotable = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low runtime on thin evidence raises no warning`() = runVmTest {
|
||||
val device = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testAddress
|
||||
every { it.model } returns PodModel.AIRPODS_PRO2
|
||||
}
|
||||
devicesFlow.value = listOf(device)
|
||||
drainProfilesFlow.value = mapOf(
|
||||
testAddress to DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO2.name,
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 1f / 3f,
|
||||
sampleCount = 10,
|
||||
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1,
|
||||
updatedAt = timeSource.now(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
|
||||
val state = vm.state.first()
|
||||
state.batteryHealth?.left?.percent shouldBe 50
|
||||
state.batteryRuntimeWarning shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the warning follows the per-device estimate toggle`() = runVmTest {
|
||||
val device = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testAddress
|
||||
every { it.model } returns PodModel.AIRPODS_PRO2
|
||||
}
|
||||
devicesFlow.value = listOf(device)
|
||||
drainProfilesFlow.value = mapOf(
|
||||
testAddress to DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO2.name,
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 1f / 3f,
|
||||
sampleCount = 10,
|
||||
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
|
||||
updatedAt = timeSource.now(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
profilesFlow.value = listOf(
|
||||
AppleDeviceProfile(
|
||||
id = testAddress,
|
||||
label = "Test",
|
||||
address = testAddress,
|
||||
batteryEstimateEnabled = false,
|
||||
)
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
|
||||
vm.state.first().batteryRuntimeWarning shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,27 +6,33 @@ import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class BatteryHealthTest : BaseTest() {
|
||||
|
||||
private fun rate(fractionPerHour: Float, updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT) =
|
||||
DrainProfile.LearnedRate(
|
||||
fractionPerHour = fractionPerHour,
|
||||
sampleCount = 10,
|
||||
updateCount = updateCount,
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
private val now: Instant = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
private fun rate(
|
||||
fractionPerHour: Float,
|
||||
updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT,
|
||||
updatedAt: Instant = now,
|
||||
) = DrainProfile.LearnedRate(
|
||||
fractionPerHour = fractionPerHour,
|
||||
sampleCount = 10,
|
||||
updateCount = updateCount,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `health is the ratio of rated to learned drain`() {
|
||||
fun `runtime 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(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `health is computed per pod`() {
|
||||
fun `runtime 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(
|
||||
@@ -35,21 +41,21 @@ class BatteryHealthTest : BaseTest() {
|
||||
"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
|
||||
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull()
|
||||
health.left?.percent shouldBe 50
|
||||
health.right?.percent 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.
|
||||
fun `runtime is capped at 100`() {
|
||||
// Idle-heavy usage drains slower than the listening rating — never report over 100%.
|
||||
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 100
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `health uses the median across a pod's learned rates`() {
|
||||
fun `runtime 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.
|
||||
@@ -60,7 +66,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
"OFF/LEFT" to rate(1f / 1.5f),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,19 +74,19 @@ class BatteryHealthTest : BaseTest() {
|
||||
val profile = DrainProfile(
|
||||
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `models without a rating have no health`() {
|
||||
fun `models without a rating have no runtime figure`() {
|
||||
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
|
||||
BatteryHealth.estimate(profile, PodModel.UNKNOWN, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no profile or no qualifying rates yields null`() {
|
||||
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2, now).shouldBeNull()
|
||||
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
model = PodModel.AIRPODS_PRO.name,
|
||||
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,7 +111,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -113,15 +119,145 @@ 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(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC, now).shouldNotBeNull().left?.percent shouldBe 50
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headset slot yields a headset figure`() {
|
||||
// AirPods Max rated 20h; managing only 10h -> 50%.
|
||||
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
|
||||
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
|
||||
health.headset shouldBe 50
|
||||
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX, now).shouldNotBeNull()
|
||||
health.headset?.percent shouldBe 50
|
||||
health.left shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reading is promotable with enough recent sessions`() {
|
||||
val profile = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sessions are summed across a pod's qualifying rates`() {
|
||||
// Neither entry alone clears the promote floor, together they do.
|
||||
val half = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT / 2
|
||||
val profile = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"ON/LEFT" to rate(1f / 3f, updateCount = half),
|
||||
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - half),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the promote session floor is enough`() {
|
||||
val atFloor = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(atFloor, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe true
|
||||
|
||||
val belowFloor = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(belowFloor, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the newest qualifying rate decides staleness`() {
|
||||
val stale = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minusSeconds(1)
|
||||
val profile = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"ON/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = stale),
|
||||
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = now),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reading exactly at the age limit still promotes`() {
|
||||
val atLimit = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(
|
||||
1f / 3f,
|
||||
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
|
||||
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE),
|
||||
),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(atLimit, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe true
|
||||
|
||||
val pastLimit = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(
|
||||
1f / 3f,
|
||||
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
|
||||
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minus(Duration.ofSeconds(1)),
|
||||
),
|
||||
)
|
||||
)
|
||||
BatteryHealth.estimate(pastLimit, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().left?.isPromotable shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lowestPromotable picks the worst pod that has enough evidence`() {
|
||||
val perPod = BatteryHealth.PerPod(
|
||||
left = BatteryHealth.Reading(percent = 60, isPromotable = true),
|
||||
right = BatteryHealth.Reading(percent = 40, isPromotable = true),
|
||||
)
|
||||
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
|
||||
slot = BatteryHealth.Slot.RIGHT,
|
||||
reading = BatteryHealth.Reading(percent = 40, isPromotable = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lowestPromotable ignores readings without enough evidence`() {
|
||||
val perPod = BatteryHealth.PerPod(
|
||||
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
|
||||
headset = BatteryHealth.Reading(percent = 70, isPromotable = true),
|
||||
)
|
||||
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
|
||||
slot = BatteryHealth.Slot.HEADSET,
|
||||
reading = BatteryHealth.Reading(percent = 70, isPromotable = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lowestPromotable is null when nothing qualifies`() {
|
||||
BatteryHealth.PerPod(
|
||||
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
|
||||
).lowestPromotable.shouldBeNull()
|
||||
BatteryHealth.PerPod().lowestPromotable.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the low runtime threshold counts as low`() {
|
||||
// Pro 2 rated 6h; 3h learned -> 50%, the threshold itself must still warn.
|
||||
val profile = DrainProfile(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
|
||||
)
|
||||
)
|
||||
val lowest = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
|
||||
.shouldNotBeNull().lowestPromotable.shouldNotBeNull()
|
||||
lowest.reading.percent shouldBe BatteryHealth.LOW_RUNTIME_PERCENT
|
||||
(lowest.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user