feat(battery): Add time-until-charged and derived battery health

- While a pod charges, fit its rising level and show the time until full in the
  gauge instead of the runtime estimate; learned charge rates are persisted per
  slot so the ETA appears immediately on later charges
- Suppress the charge ETA during Optimized Battery Charging holds, the final
  trickle phase, and whenever the level stalls longer than one visible step
  should take (granularity-aware: 1% AAP steps vs 10% BLE steps)
- Clear a slot's fit window when its readings switch between AAP and BLE — the
  granularity jump would otherwise read as a fake level step
- Derive a battery-health percentage (median of accumulated drain rates vs the
  model's rated life) and show it in the device info sheet; the info button now
  also appears for BLE-only devices once health data exists
- Tag learned rates with the model they came from so re-pointing a profile at
  different hardware starts learning fresh instead of inheriting foreign rates
- Track how many sessions blended into each learned rate and require three
  before a health figure is shown
This commit is contained in:
darken
2026-07-02 02:28:43 +02:00
parent 8c3badb2b9
commit 177d5366f4
17 changed files with 896 additions and 64 deletions
@@ -295,6 +295,9 @@ 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)
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
@@ -22,7 +22,10 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
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.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -57,6 +60,7 @@ class DeviceSettingsViewModel @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val batteryEstimator: BatteryEstimator,
private val drainStore: BatteryDrainStore,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
@@ -124,6 +128,7 @@ class DeviceSettingsViewModel @Inject constructor(
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
nudgeCapabilityStore.availability,
drainStore.profiles,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info
@@ -136,6 +141,9 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val nudgeAvailability = args[7] as NudgeAvailability
@Suppress("UNCHECKED_CAST")
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
val stemActions = appleProfile?.stemActions
@@ -161,6 +169,11 @@ 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.
batteryHealthPercent = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimatePercent(drainProfiles[profileId], it.model) },
)
}
}.asLiveState()
@@ -187,6 +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,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -18,6 +18,7 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
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),
)
internal data class DeviceInfoDetailLabels(
@@ -31,14 +32,20 @@ internal data class DeviceInfoDetailLabels(
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
val batteryHealth: String,
)
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
batteryHealth: String? = null,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
if (info == null) return emptyList()
// 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()
}
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
@@ -82,5 +89,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)) }
}
}
@@ -1,5 +1,6 @@
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
@@ -239,7 +240,7 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
timeRemaining = batteryEstimate?.left?.let { formatEstimateText(context, it) },
)
PodGauge(
@@ -252,7 +253,7 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
timeRemaining = batteryEstimate?.right?.let { formatEstimateText(context, it) },
)
}
@@ -404,6 +405,16 @@ private fun PodGauge(
}
}
/**
* The gauge's small estimate line: while charging with a usable rate, the time until full
* (language-neutral "⚡ 25m"); otherwise the usual time-remaining ("2h 15m"). Shared with
* [SinglePodsCard] (same package).
*/
internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod): String =
pod.minutesUntilCharged
?.let { context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, it)) }
?: formatBatteryDurationShort(context, pod.minutesRemaining)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CaseRow(
@@ -270,7 +270,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
text = formatEstimateText(context, headsetEstimate),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -16,11 +16,15 @@ data class BatteryEstimate(
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
* 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].
*/
data class Pod(
val minutesRemaining: Int,
val fractionPerHour: Float,
val source: Source,
val minutesUntilCharged: Int? = null,
) {
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
val isProvisional: Boolean get() = source == Source.SPEC
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
@@ -51,12 +52,31 @@ class BatteryEstimator @Inject constructor(
private enum class Slot { LEFT, RIGHT, HEADSET }
/** Which transport a battery reading came from — AAP is 1% granularity, BLE 10%. */
private enum class DataSource { AAP, BLE }
private class SlotHistory {
enum class Direction { DRAIN, CHARGE }
var direction: Direction = Direction.DRAIN
private set
private var source: DataSource? = null
private val samples = ArrayDeque<DrainSample>()
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
val size: Int get() = samples.size
/**
* Keeps the window only while it still describes the same thing: a direction flip
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
*/
fun realign(direction: Direction, source: DataSource) {
if (this.direction != direction || this.source != source) samples.clear()
this.direction = direction
this.source = source
}
fun record(sample: DrainSample) {
samples.addLast(sample)
while (samples.size > RING_SIZE) samples.removeFirst()
@@ -75,21 +95,29 @@ class BatteryEstimator @Inject constructor(
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
var lastUpdateMs: Long? = null
// Keyed by "<bucket>/<slot>".
/** When each slot's level last visibly ROSE while charging — drives stall suppression. */
val lastRiseMs: MutableMap<Slot, Long> = mutableMapOf()
// Keyed by "<bucket>/<slot>" for drain rates, "CHARGE/<slot>" for charge rates.
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
/**
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
* during a single session blend against a fixed baseline instead of runaway-converging.
* [sessionBaselineCounts] captures the matching pre-session updateCount, so repeated persists
* within one session count as ONE update, not many.
*/
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
val sessionBaselineCounts: MutableMap<String, Int> = mutableMapOf()
fun clearSlots() = slots.values.forEach { it.clear() }
fun resetWindow() {
clearSlots()
lastMinutes.clear()
lastRiseMs.clear()
sessionBaseline.clear()
sessionBaselineCounts.clear()
}
}
@@ -171,15 +199,48 @@ class BatteryEstimator @Inject constructor(
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val charging = device.liveCharging(slot)
val fraction = device.liveFraction(slot)
val reading = device.liveReading(slot)
when {
charging == true -> { // charging → battery is rising, not draining
reading == null -> { // unavailable reading → just drop this slot's window
history.clear()
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop this pod's smoothing
tracker.lastRiseMs.remove(slot)
// A charging jump breaks discharge continuity even when the level is unreadable.
if (charging == true) tracker.lastMinutes.remove(slot)
}
fraction == null -> history.clear() // unavailable reading → just drop this slot's window
else -> {
charging == true -> { // battery rising → sample the CHARGE, never a drain
val (fraction, source) = reading
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop discharge smoothing
if (device.liveChargingOptimized(slot)) {
// Optimized Battery Charging parks the level below full for hours while
// still flagged charging — fitting that plateau would learn garbage.
history.clear()
tracker.lastRiseMs.remove(slot)
} else {
history.realign(SlotHistory.Direction.CHARGE, source)
val last = history.lastFraction
when {
last == null -> { // charge session starts (or resumes) for this slot
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction > last + EPSILON -> {
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction < last - EPSILON -> { // level DROPPED while charging → reseat/swap
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
else -> Unit // ~unchanged; stall detection judges the silence
}
}
}
else -> { // draining (or unknown charging state — treated as draining, as before)
val (fraction, source) = reading
tracker.lastRiseMs.remove(slot)
history.realign(SlotHistory.Direction.DRAIN, source)
val last = history.lastFraction
when {
last == null -> history.record(DrainSample(nowMs, fraction))
@@ -196,7 +257,7 @@ class BatteryEstimator @Inject constructor(
}
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
return computeEstimate(profileId, tracker, device, bucket)
return computeEstimate(profileId, tracker, device, bucket, nowMs)
}
/** Computes an independent estimate for each pod (left / right / headset). */
@@ -205,11 +266,12 @@ class BatteryEstimator @Inject constructor(
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
): BatteryEstimate? {
val estimate = BatteryEstimate(
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT),
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT),
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET),
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT, nowMs),
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT, nowMs),
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET, nowMs),
)
return estimate.takeIf { it.hasAny }
}
@@ -220,6 +282,7 @@ class BatteryEstimator @Inject constructor(
device: PodDevice,
bucket: String,
slot: Slot,
nowMs: Long,
): BatteryEstimate.Pod? {
val fraction = device.liveFraction(slot) ?: return null
@@ -236,7 +299,7 @@ class BatteryEstimator @Inject constructor(
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
?.takeIf { plausibleForModel(it, spec) }
}
val learned = learnedRate(profileId, bucket, slot)
val learned = learnedRate(profileId, device, bucket, slot)
val displayRate = live ?: learned ?: spec ?: return null
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
@@ -263,12 +326,42 @@ class BatteryEstimator @Inject constructor(
minutesRemaining = smoothed,
fractionPerHour = effectiveRate,
source = source,
minutesUntilCharged = if (charging) chargeEstimate(profileId, tracker, device, slot, fraction, nowMs) else null,
)
}
/**
* Persists each pod's live drain rate under its (bucket, slot) key, at most once per
* [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence) unless [force]d (mode change).
* Minutes until [slot] is full, or null when no usable charge rate exists, the pod is in an
* Optimized Battery Charging hold, or the level has sat still longer than one visible step
* should take (stall — trickle phase or an unreported hold; a linear ETA would just freeze).
*/
private fun chargeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
slot: Slot,
fraction: Float,
nowMs: Long,
): Int? {
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
val history = tracker.slots.getValue(slot)
val live = if (history.direction == SlotHistory.Direction.CHARGE) {
DrainModel.chargeSlopeFractionPerHour(history.toList())
} else null
val rate = live ?: learnedChargeRate(profileId, device, slot) ?: return null
val lastRise = tracker.lastRiseMs[slot] ?: return null
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(rate, step)) return null
return DrainModel.minutesUntilFull(fraction, rate)
}
/**
* Persists each pod's live drain or charge rate (whichever direction its window currently
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
* unless [force]d (mode change). Drain rates are keyed per (bucket, slot); charge rates per slot
* only — the ANC mode doesn't apply inside the case.
*/
private suspend fun persistFromWindow(
profileId: ProfileId,
@@ -278,47 +371,74 @@ class BatteryEstimator @Inject constructor(
nowMs: Long,
force: Boolean,
) {
val existing = drainStore.profiles.value[profileId] ?: DrainProfile()
// A profile tagged with DIFFERENT hardware means the user re-pointed it — its rates don't
// describe this device, so learning starts over instead of blending into foreign history.
val existing = drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
?: DrainProfile()
val spec = device.specRate(bucket)
var rates = existing.rates
var chargeRates = existing.chargeRates
var changed = false
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val isCharge = history.direction == SlotHistory.Direction.CHARGE
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
val liveRate = DrainModel.slopeFractionPerHour(history.toList())
?.takeIf { plausibleForModel(it, spec) } ?: continue
val liveRate = if (isCharge) {
DrainModel.chargeSlopeFractionPerHour(history.toList())
} else {
DrainModel.slopeFractionPerHour(history.toList())?.takeIf { plausibleForModel(it, spec) }
} ?: continue
val key = rateKey(bucket, slot)
val key = if (isCharge) chargeRateKey(slot) else rateKey(bucket, slot)
val lastPersist = tracker.lastPersistAtMs[key]
if (!force && lastPersist != null && nowMs - lastPersist < PERSIST_INTERVAL_MS) continue
tracker.lastPersistAtMs[key] = nowMs
// Blend against the rate stored when this session began, captured once, so a single long
// session's repeated writes can't dominate prior history by re-blending their own output.
// The captured updateCount keeps a whole session counting as ONE accumulated update.
val stored = if (isCharge) chargeRates[slot.name] else rates[key]
if (!tracker.sessionBaseline.containsKey(key)) {
tracker.sessionBaseline[key] = rates[key]?.fractionPerHour
tracker.sessionBaseline[key] = stored?.fractionPerHour
tracker.sessionBaselineCounts[key] = stored?.updateCount ?: 0
}
val blended = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate)
rates = rates + (key to DrainProfile.LearnedRate(
fractionPerHour = blended,
val learned = DrainProfile.LearnedRate(
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate),
sampleCount = history.size,
updateCount = (tracker.sessionBaselineCounts[key] ?: 0) + 1,
updatedAt = timeSource.now(),
))
)
if (isCharge) chargeRates = chargeRates + (slot.name to learned) else rates = rates + (key to learned)
changed = true
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(blended)}/hr" }
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(learned.fractionPerHour)}/hr" }
}
if (changed) drainStore.save(profileId, existing.copy(rates = rates))
if (changed) {
drainStore.save(
profileId,
existing.copy(model = device.model.name, rates = rates, chargeRates = chargeRates),
)
}
}
private fun learnedRate(profileId: ProfileId, bucket: String, slot: Slot): Float? {
val profile = drainStore.profiles.value[profileId] ?: return null
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour
}
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
/** The stored profile, ignored entirely when its rates were learned on different hardware. */
private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? =
drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
/** Session-state key for charge windows — namespaced so it can't collide with an ANC bucket. */
private fun chargeRateKey(slot: Slot): String = "CHARGE/${slot.name}"
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
/**
@@ -350,14 +470,28 @@ class BatteryEstimator @Inject constructor(
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
// cache); learning from a re-stamped stale reading would poison the rate.
private fun PodDevice.liveFraction(slot: Slot): Float? {
val value = when (slot) {
Slot.LEFT -> aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
Slot.RIGHT -> aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
private fun PodDevice.liveFraction(slot: Slot): Float? = liveReading(slot)?.first
/**
* The slot's live battery fraction plus which transport reported it. The source matters because
* the two granularities (AAP 1%, BLE 10%) can't share a fit window — see [SlotHistory.realign].
*/
private fun PodDevice.liveReading(slot: Slot): Pair<Float, DataSource>? {
val aapValue = when (slot) {
Slot.LEFT -> aap?.batteryLeft
Slot.RIGHT -> aap?.batteryRight
Slot.HEADSET -> aap?.batteryHeadset
}
val (value, source) = when {
aapValue != null -> aapValue to DataSource.AAP
else -> when (slot) {
Slot.LEFT -> (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
Slot.RIGHT -> (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
}?.let { it to DataSource.BLE } ?: return null
}
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
return value?.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)
return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source }
}
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
@@ -366,13 +500,24 @@ class BatteryEstimator @Inject constructor(
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
}
/** Only AAP reports the Optimized Battery Charging hold; BLE can't distinguish it. */
private fun PodDevice.liveChargingOptimized(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> aap?.leftChargingState
Slot.RIGHT -> aap?.rightChargingState
Slot.HEADSET -> aap?.headsetChargingState
} == AapPodState.ChargingState.CHARGING_OPTIMIZED
companion object {
private val TAG = logTag("Monitor", "BatteryEstimator")
private const val RING_SIZE = 32
private const val EPSILON = 0.001f
private const val MODE_UNKNOWN = "UNKNOWN"
private const val MODE_UNKNOWN = DrainProfile.BUCKET_UNKNOWN
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
/** Visible battery step per transport — feeds the granularity-aware stall threshold. */
private const val STEP_AAP = 0.01f
private const val STEP_BLE = 0.10f
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
private const val SPEC_BAND_MAX = 4f
@@ -0,0 +1,70 @@
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 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%.
*
* 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.
*/
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")
fun estimatePercent(profile: DrainProfile?, model: PodModel): Int? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
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.
val parts = key.split('/')
if (parts.size != 2 || parts[1] !in VALID_SLOTS) 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
}
if (ratios.isEmpty()) return null
val sorted = ratios.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)
}
/**
* 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.
* Malformed or unrecognized bucket keys yield null (entry is skipped).
*/
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
return when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
AapSetting.AncMode.Value.ON.name,
AapSetting.AncMode.Value.TRANSPARENCY.name,
AapSetting.AncMode.Value.ADAPTIVE.name,
-> on ?: off
DrainProfile.BUCKET_UNKNOWN -> listOfNotNull(on, off).takeIf { it.isNotEmpty() }?.average()?.toFloat()
else -> null
}?.takeIf { it.isFinite() && it > 0f }
}
}
@@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core.battery
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* A single battery observation for one slot (left / right / headset).
@@ -44,6 +45,29 @@ object DrainModel {
const val RATE_MIN = 0.02f
const val RATE_MAX = 0.80f
/**
* Charge fits get by with fewer samples than drain fits: a charge session is short (well under
* an hour) and BLE's 10% steps would otherwise need most of the session before a fit exists.
*/
const val MIN_SAMPLES_CHARGE = 3
/** Minimum total rise across the charge window — rejects jitter that isn't a real charge. */
const val MIN_TOTAL_RISE = 0.05f
/** Plausible charge-rate band (fraction/hour): a full charge in 15 minutes .. 4 hours. */
const val CHARGE_RATE_MIN = 0.25f
const val CHARGE_RATE_MAX = 4.0f
/**
* Above this level the "until charged" estimate is suppressed: the final trickle phase is far
* slower than the linear bulk of the curve, so a linear fit would show a perpetually-imminent
* finish. The firmware flips the charging flag off at 100% anyway.
*/
const val NEAR_FULL_SUPPRESS = 0.97f
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
/** Estimates above this are implausible and suppressed. */
const val MAX_MINUTES = 24 * 60
@@ -65,18 +89,68 @@ object DrainModel {
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
*/
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
if (samples.size < MIN_SAMPLES) return null
val recent = recentWindow(samples, MIN_SAMPLES) ?: return null
if (recent.first().fraction - recent.last().fraction < MIN_TOTAL_DROP) return null
// Negative slope == draining; flip to a positive drain rate.
val rate = regressionSlopePerHour(recent)?.let { -it } ?: return null
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
return rate
}
/**
* Least-squares slope of RISING [samples] (a charging pod) as a positive charge rate in
* fraction/hour, with the same window guards as the drain fit but charge-tuned thresholds.
*/
fun chargeSlopeFractionPerHour(samples: List<DrainSample>): Float? {
val recent = recentWindow(samples, MIN_SAMPLES_CHARGE) ?: return null
if (recent.last().fraction - recent.first().fraction < MIN_TOTAL_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
if (!rate.isFinite() || rate < CHARGE_RATE_MIN || rate > CHARGE_RATE_MAX) return null
return rate
}
/**
* Minutes until [levelFraction] reaches full at [chargeFractionPerHour], or null when the rate
* is non-positive, the level is already in the trickle zone ([NEAR_FULL_SUPPRESS]), or the
* result is implausible.
*/
fun minutesUntilFull(levelFraction: Float, chargeFractionPerHour: Float): Int? {
if (chargeFractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction < 0f) return null
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
val minutes = ((1f - levelFraction) / chargeFractionPerHour * 60.0).roundToInt()
return minutes.takeIf { it in 1..MAX_MINUTES }
}
/**
* How long the level may sit unchanged while charging before the "until charged" estimate is
* considered stalled (Optimized Battery Charging hold, trickle phase, or a dead reading) and
* suppressed. Granularity-aware: at [chargeFractionPerHour] a single visible step of
* [stepFraction] (1% on AAP, 10% on BLE) takes `step/rate` hours — a slow BLE charge legitimately
* shows no change for ~20 minutes, so a fixed timeout would falsely suppress it.
*/
fun chargeStallThresholdMs(chargeFractionPerHour: Float, stepFraction: Float): Long {
if (chargeFractionPerHour <= 0f) return CHARGE_STALL_FLOOR_MS
val stepMs = (stepFraction.toDouble() / chargeFractionPerHour * 3_600_000).roundToLong()
return maxOf(CHARGE_STALL_FLOOR_MS, stepMs * 3 / 2)
}
/** Samples within [MAX_SAMPLE_AGE_MS] of the newest, or null when count/span guards fail. */
private fun recentWindow(samples: List<DrainSample>, minSamples: Int): List<DrainSample>? {
if (samples.size < minSamples) return null
// Restrict to the recent window so a gap before the newest sample can't span the fit.
val newestMs = samples.last().atElapsedMs
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
if (recent.size < MIN_SAMPLES) return null
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
return recent
}
/** Signed least-squares slope (fraction per hour) over [recent]; negative when draining. */
private fun regressionSlopePerHour(recent: List<DrainSample>): Float? {
val first = recent.first()
val last = recent.last()
if (last.atElapsedMs - first.atElapsedMs < MIN_SPAN_MS) return null
if (first.fraction - last.fraction < MIN_TOTAL_DROP) return null
val n = recent.size.toDouble()
var sumX = 0.0
var sumY = 0.0
@@ -92,11 +166,7 @@ object DrainModel {
}
val denominator = n * sumXX - sumX * sumX
if (abs(denominator) < 1e-9) return null
// Negative slope == draining; flip to a positive drain rate.
val rate = (-((n * sumXY - sumX * sumY) / denominator)).toFloat()
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
return rate
return ((n * sumXY - sumX * sumY) / denominator).toFloat()
}
/**
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.pods.core.apple.PodModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@@ -10,17 +11,47 @@ import java.time.Instant
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
*
* [chargeRates] is the charging counterpart, keyed per slot only (`"LEFT"` / `"RIGHT"` /
* `"HEADSET"`) — the ANC mode is irrelevant while a pod sits in the case.
*
* [model] tags which [PodModel] the rates were learned on, so a profile re-assigned to different
* hardware doesn't inherit the old device's rates (see [matchesModel]).
*/
@Serializable
data class DrainProfile(
@SerialName("model") val model: String? = null,
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
) {
@Serializable
data class LearnedRate(
/** Drain rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
/** Drain (or charge) rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
@SerialName("fractionPerHour") val fractionPerHour: Float,
@SerialName("sampleCount") val sampleCount: Int,
/**
* How many distinct sessions have blended into this rate. [sampleCount] is only the window
* size at the LAST save, so this is the actual accumulated-evidence signal (used e.g. to
* gate the derived battery-health figure).
*/
@SerialName("updateCount") val updateCount: Int = 1,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
/**
* Whether these learned rates apply to [model]. An untagged profile or an UNKNOWN model on
* either side is treated as matching — only a definite known-A vs known-B mismatch (the user
* re-pointed the profile at different hardware) disqualifies the data.
*/
fun matchesModel(model: PodModel): Boolean =
this.model == null ||
model == PodModel.UNKNOWN ||
this.model == PodModel.UNKNOWN.name ||
this.model == model.name
companion object {
/** Bucket key for rates learned while the ANC mode wasn't known (BLE-only sessions). */
const val BUCKET_UNKNOWN = "UNKNOWN"
}
}
+3
View File
@@ -319,6 +319,7 @@
<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>
@@ -476,6 +477,8 @@
<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_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>
<string name="device_settings_info_status_label">Status</string>
@@ -12,12 +12,16 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
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.DrainProfile
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import io.kotest.matchers.shouldBe
@@ -64,6 +68,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var batteryEstimator: BatteryEstimator
private lateinit var drainStore: BatteryDrainStore
private lateinit var drainProfilesFlow: MutableStateFlow<Map<ProfileId, DrainProfile>>
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
@@ -119,6 +125,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { profiles } returns profilesFlow
}
batteryEstimator = mockk(relaxed = true)
drainProfilesFlow = MutableStateFlow(emptyMap())
drainStore = mockk<BatteryDrainStore>().also {
every { it.profiles } returns drainProfilesFlow
}
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
@@ -144,6 +154,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
batteryEstimator = batteryEstimator,
drainStore = drainStore,
monitorModeResolver = monitorModeResolver,
nudgeCapabilityStore = nudgeCapabilityStore,
timeSource = timeSource,
@@ -535,6 +546,69 @@ class DeviceSettingsViewModelTest : BaseTest() {
vm.state.first().batteryEstimateEnabled shouldBe false
}
@Test
fun `state derives battery health from learned rates`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
// Rated 6h, learned 3h of runtime (0.333/hr) -> ~50% health.
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = 3,
updatedAt = java.time.Instant.EPOCH,
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe 50
}
@Test
fun `battery health hides when the estimate is disabled for the device`() = 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,
rates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = 3,
updatedAt = java.time.Instant.EPOCH,
)
),
)
)
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealthPercent shouldBe null
}
@Test
fun `setBatteryEstimateEnabled updates the profile`() = runVmTest {
val vm = createViewModel()
@@ -20,6 +20,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
rightSerial = "Right Pod Serial",
leftBonded = "Left Bonded",
rightBonded = "Right Bonded",
batteryHealth = "Battery Health",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
@@ -52,7 +53,32 @@ class DeviceInfoDetailItemsTest : BaseTest() {
@Test
fun `null AapDeviceInfo yields empty list`() {
buildDeviceInfoDetailItems(null, labels, formatter) shouldBe emptyList()
buildDeviceInfoDetailItems(null, labels, formatDate = formatter) shouldBe emptyList()
}
@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)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Battery Health", "~85%"),
)
}
@Test
fun `battery health is appended after the info rows`() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
batteryHealth = "~72%",
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
DeviceDetailItem.Single("Battery Health", "~72%"),
)
}
@Test
@@ -60,7 +86,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -79,7 +105,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
firmwareVersion = "7A305",
),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -100,7 +126,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
marketingVersion = "8454768",
),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
@@ -116,7 +142,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", marketingVersion = "8454768"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
@@ -129,7 +155,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", firmwareVersionPending = " "),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
@@ -141,7 +167,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL", rightEarbudSerial = "RRR"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -156,7 +182,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Pod Serial", "LLL"),
@@ -168,7 +194,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudSerial = "RRR"),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Pod Serial", "RRR"),
@@ -181,7 +207,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = sameSecond, rightEarbudFirstPaired = sameSecond),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -198,7 +224,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = left, rightEarbudFirstPaired = right),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
@@ -213,7 +239,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Bonded", "fmt:1697480211"),
@@ -225,7 +251,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
formatDate = formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Bonded", "fmt:1697480211"),
@@ -241,7 +267,7 @@ class DeviceInfoDetailItemsTest : BaseTest() {
rightEarbudFirstPaired = null,
),
labels,
formatter,
formatDate = formatter,
)
result.none { it is DeviceDetailItem.Paired } shouldBe true
result.none {
@@ -33,10 +33,15 @@ class BatteryEstimatorTest : BaseTest() {
left: Float?,
right: Float?,
charging: Boolean = false,
optimized: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
): PodDevice {
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
charging -> ChargingState.CHARGING
else -> ChargingState.NOT_CHARGING
}
val batteries = buildMap {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
@@ -251,6 +256,143 @@ class BatteryEstimatorTest : BaseTest() {
collectEstimate(estimator(emissions)) shouldBe emptyMap()
}
@Test
fun `a rising charge yields a live time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// 2%/min while docked -> 1.2 fraction/hr -> at 44% that's (1 - 0.44) / 1.2 * 60 == 28 min.
val emissions = (0 until 4).map { i ->
val level = 0.20f + i * 0.08f
listOf(device("p1", left = level, right = level, charging = true, model = PodModel.AIRPODS_PRO2))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.minutesUntilCharged shouldBe 28
}
@Test
fun `a stored charge rate seeds time-until-charged immediately`() = runTest(UnconfinedTestDispatcher()) {
// First charging emission, no live fit possible yet -> the persisted rate answers at once.
// 50% missing at 1.2/hr == 25 min.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 25
}
@Test
fun `an optimized-charging hold suppresses time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// CHARGING_OPTIMIZED parks the level below full — an ETA would mislead, but the runtime
// projection stays visible.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val result = collectEstimate(
estimator(
emissions = listOf(
listOf(device("p1", left = 0.80f, right = 0.80f, charging = true, optimized = true, model = PodModel.AIRPODS_PRO2))
),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.minutesUntilCharged shouldBe null
left.source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `a stalled charge suppresses time-until-charged`() = runTest(UnconfinedTestDispatcher()) {
// Level stops rising while still flagged charging (unreported hold / trickle): once the
// silence outlasts the stall threshold the frozen ETA is dropped.
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val emissions = listOf(
listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2)),
)
val result = collectEstimate(
estimator(emissions, stored = stored, clockMs = listOf(0L, 11 * 60_000L)),
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe null
}
@Test
fun `a discharging pod has no charge estimate`() = runTest(UnconfinedTestDispatcher()) {
val stored = mapOf(
"p1" to DrainProfile(chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)))
)
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions, stored = stored))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LIVE
left.minutesUntilCharged shouldBe null
}
@Test
fun `charge rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val emissions = (0 until 4).map { i ->
val level = 0.20f + i * 0.08f
listOf(device("p1", left = level, right = level, charging = true, model = PodModel.AIRPODS_PRO2))
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
estimator.monitor().collect {}
coVerify {
drainStore.save("p1", match { it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") })
}
}
@Test
fun `undocking does not leak charge samples into the drain fit`() = runTest(UnconfinedTestDispatcher()) {
// A charge session builds a rising window; the moment the pods leave the case the window
// must flip to drain from scratch — a fit across the rising samples would be garbage.
val emissions = listOf(
listOf(device("p1", left = 0.20f, right = 0.20f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.28f, right = 0.28f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.36f, right = 0.36f, charging = true, model = PodModel.AIRPODS_PRO2)),
listOf(device("p1", left = 0.36f, right = 0.36f, model = PodModel.AIRPODS_PRO2)), // undocked
)
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
// One drain sample only -> no live fit, nothing learned -> the rating answers.
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesUntilCharged shouldBe null
}
@Test
fun `learned rates from different hardware are ignored`() = runTest(UnconfinedTestDispatcher()) {
// The profile was re-pointed from an AirPods Pro to a Pro 2 — its old rates don't describe
// this device, so the estimate falls back to the current model's rating.
val stored = mapOf(
"p1" to DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f)),
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -0,0 +1,100 @@
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.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
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,
)
@Test
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
}
@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
}
@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.
val profile = DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 6f),
"UNKNOWN/RIGHT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
}
@Test
fun `rates without enough accumulated sessions are ignored`() {
val profile = DrainProfile(
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimatePercent(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()
}
@Test
fun `no profile or no qualifying rates yields null`() {
BatteryHealth.estimatePercent(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimatePercent(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `rates learned on different hardware are ignored`() {
val profile = DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `malformed bucket keys and broken rates are skipped`() {
val profile = DrainProfile(
rates = mapOf(
"GARBAGE/LEFT" to rate(1f / 3f), // unrecognized bucket
"UNKNOWN" to rate(1f / 3f), // no slot at all
"UNKNOWN/" to rate(1f / 3f), // blank slot
"UNKNOWN/CASE" to rate(1f / 3f), // not an estimated slot
"UNKNOWN/LEFT/EXTRA" to rate(1f / 3f), // extra path component
"UNKNOWN/LEFT" to rate(0f), // non-positive rate
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `mode-specific rates are judged against their own rating`() {
// 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
}
}
@@ -114,4 +114,76 @@ class DrainModelTest : BaseTest() {
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.90f, 0.003f, count = 8))!!
(rate.isFinite() && rate > 0f) shouldBe true
}
/** Samples charging at a constant rate, [perMinute] fraction gained per minute. */
private fun chargingSamples(
start: Float,
perMinute: Float,
count: Int,
stepMinutes: Long = 4,
): List<DrainSample> = (0 until count).map { i ->
DrainSample(
atElapsedMs = i * stepMinutes * 60_000L,
fraction = start + perMinute * (i * stepMinutes),
)
}
@Test
fun `charge slope recovers a constant charge rate in fraction per hour`() {
// 2% per minute == 120% per hour == 1.2 fraction/hour (a ~50 min full charge).
val rate = DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 4))
rate.shouldNotBeNull()
rate shouldBe (1.2f plusOrMinus 0.05f)
}
@Test
fun `a draining pod is not a charge`() {
DrainModel.chargeSlopeFractionPerHour(drainingSamples(0.80f, 0.02f, count = 4)).shouldBeNull()
}
@Test
fun `a negligible rise is rejected`() {
// Long window but total rise below MIN_TOTAL_RISE.
val samples = (0 until 4).map { DrainSample(it * 5 * 60_000L, 0.50f + it * 0.005f) }
DrainModel.chargeSlopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `an implausibly slow charge is rejected`() {
// ~6%/hr would mean a 16-hour charge — outside CHARGE_RATE_MIN.
val samples = (0 until 4).map { DrainSample(it * 20 * 60_000L, 0.30f + it * 0.02f) }
DrainModel.chargeSlopeFractionPerHour(samples).shouldBeNull()
}
@Test
fun `charge fits need fewer samples than drain fits`() {
// 3 samples is enough for a charge fit (BLE's 10% steps make more expensive)...
DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 3)).shouldNotBeNull()
// ...but not fewer.
DrainModel.chargeSlopeFractionPerHour(chargingSamples(0.20f, 0.02f, count = 2)).shouldBeNull()
}
@Test
fun `minutesUntilFull divides the missing fraction by the rate`() {
// 40% missing at 1.2/hr -> 0.4 / 1.2 * 60 = 20 minutes. A fraction, never a percent.
DrainModel.minutesUntilFull(0.60f, 1.2f) shouldBe 20
}
@Test
fun `minutesUntilFull suppresses the trickle zone`() {
DrainModel.minutesUntilFull(0.98f, 1.2f).shouldBeNull()
}
@Test
fun `minutesUntilFull rejects a non-positive rate`() {
DrainModel.minutesUntilFull(0.60f, 0f).shouldBeNull()
}
@Test
fun `charge stall threshold is granularity aware`() {
// AAP's 1% step at 1.2/hr passes in ~30s -> the 10-minute floor applies.
DrainModel.chargeStallThresholdMs(1.2f, 0.01f) shouldBe DrainModel.CHARGE_STALL_FLOOR_MS
// BLE's 10% step at a slow 0.3/hr takes 20 min -> the threshold must exceed it (30 min).
DrainModel.chargeStallThresholdMs(0.3f, 0.10f) shouldBe 30 * 60_000L
}
}
@@ -0,0 +1,58 @@
package eu.darken.capod.monitor.core.battery
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class DrainProfileSerializationTest : BaseTest() {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `profiles stored before charge rates and the model tag decode with defaults`() {
val legacyJson = """
{
"rates": {
"UNKNOWN/LEFT": {
"fractionPerHour": 0.15,
"sampleCount": 12,
"updatedAt": 1700000000000
}
}
}
""".trimIndent()
val profile = json.decodeFromString<DrainProfile>(legacyJson)
profile.model shouldBe null
profile.chargeRates shouldBe emptyMap()
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
}
@Test
fun `full profile round-trips`() {
val profile = DrainProfile(
model = "AIRPODS_PRO2",
rates = mapOf(
"ON/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 0.21f,
sampleCount = 9,
updateCount = 4,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
chargeRates = mapOf(
"LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1.3f,
sampleCount = 5,
updateCount = 2,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
)
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile
}
}