feat(battery): Model charge taper per band, base health on listening-only drain

- Replace the single linear charge rate with a three-band model (bulk / taper /
  trickle) matching lithium CC/CV charging: each band learns its own rate, the
  ETA walks the remaining bands, and the spec seed gets a taper haircut for the
  slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
  worn, audio is playing, AND this device is the system's audio sink; idle wear
  previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
  (playback stop, docking, transport flip) instead of being discarded with the
  cleared window
- The time-remaining estimate keeps learning from all usage — actual current
  drain, idle included, is the right basis for "how long will they last"
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 5de5dee52f
commit afba33dd83
9 changed files with 520 additions and 87 deletions
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core.battery
import android.media.AudioManager
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
@@ -45,6 +46,7 @@ class BatteryEstimator @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val drainStore: BatteryDrainStore,
private val timeSource: TimeSource,
private val audioManager: AudioManager,
) {
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
@@ -71,8 +73,11 @@ class BatteryEstimator @Inject constructor(
* (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 matches(direction: Direction, source: DataSource): Boolean =
this.direction == direction && this.source == source
fun realign(direction: Direction, source: DataSource) {
if (this.direction != direction || this.source != source) samples.clear()
if (!matches(direction, source)) samples.clear()
this.direction = direction
this.source = source
}
@@ -91,6 +96,15 @@ class BatteryEstimator @Inject constructor(
var modeBucket: String = MODE_UNKNOWN
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/**
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
* is the system's audio sink — pure listening segments, the basis for battery health.
*/
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
/** Smoothed displayed minutes, per pod. */
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
var lastUpdateMs: Long? = null
@@ -114,6 +128,8 @@ class BatteryEstimator @Inject constructor(
fun resetWindow() {
clearSlots()
listeningSlots.values.forEach { it.clear() }
pendingListeningFits.clear()
lastMinutes.clear()
lastRiseMs.clear()
sessionBaseline.clear()
@@ -169,15 +185,18 @@ class BatteryEstimator @Inject constructor(
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
next.keys.retainAll(unambiguous.keys)
// Sampled once per emission — the gate for health-grade "listening" segments.
val musicActive = audioManager.isMusicActive
for ((profileId, device) in unambiguous) {
val estimate = updateTracker(profileId, device)
val estimate = updateTracker(profileId, device, musicActive)
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
}
_estimates.value = next
}
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice): BatteryEstimate? {
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
val nowMs = timeSource.elapsedRealtime()
val bucket = device.modeBucket()
@@ -254,6 +273,36 @@ class BatteryEstimator @Inject constructor(
}
}
}
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
// and this device the audio sink (isMusicActive alone would count phone-speaker
// playback). The moment the gate breaks, the finished segment's fit is captured for
// persistence and the window cleared; mixed idle/listening samples would flatten the
// slope and re-dilute health.
val listening = charging != true && reading != null &&
musicActive && device.isSystemConnected && device.wornForSlot(slot)
val listeningHistory = tracker.listeningSlots.getValue(slot)
if (listening) {
val (fraction, source) = reading!!
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
// than letting realign silently discard it.
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
captureListeningSegment(tracker, slot)
}
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
val last = listeningHistory.lastFraction
when {
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
listeningHistory.clear()
listeningHistory.record(DrainSample(nowMs, fraction))
}
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
else -> Unit
}
} else {
captureListeningSegment(tracker, slot)
}
}
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
@@ -345,24 +394,58 @@ class BatteryEstimator @Inject constructor(
): 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
// Rate preference mirrors the drain side: measured, then learned, then Apple's published
// quick-charge claim ("5 minutes in the case = ~1 hour of listening") — so an ETA exists
// even on the very first charge.
val rate = live
?: learnedChargeRate(profileId, device, slot)
?: device.model.batterySpec?.chargeFractionPerHour
?: return null
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
val learnedScalar = learnedChargeRate(profileId, device, slot)
val specRate = device.model.batterySpec?.chargeFractionPerHour
// Per band: this session's in-band fit, then the learned band rate, then the scalar
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
// measures the bulk phase; scalars already average what was actually observed).
fun rateFor(band: DrainModel.ChargeBand): Float? =
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
?: learnedChargeBand(profileId, device, slot, band)
?: liveScalar
?: learnedScalar
?: specRate?.let { it * band.specMultiplier }
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
?: DrainModel.ChargeBand.TRICKLE
val stallRate = rateFor(currentBand) ?: 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
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
return DrainModel.minutesUntilFull(fraction, rate)
return DrainModel.minutesUntilFull(fraction, ::rateFor)
}
private fun learnedChargeBand(
profileId: ProfileId,
device: PodDevice,
slot: Slot,
band: DrainModel.ChargeBand,
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
/**
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
* persist pass writes it, bypassing cadence) and the window cleared either way.
*/
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
val history = tracker.listeningSlots.getValue(slot)
if (history.size == 0) return
DrainModel.slopeFractionPerHour(history.toList())?.let {
tracker.pendingListeningFits[slot] = it to history.size
}
history.clear()
}
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> isLeftInEar
Slot.RIGHT -> isRightInEar
Slot.HEADSET -> isBeingWorn
} == true
/**
* 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)
@@ -386,44 +469,100 @@ class BatteryEstimator @Inject constructor(
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 = if (isCharge) {
DrainModel.chargeSlopeFractionPerHour(history.toList())
} else {
DrainModel.slopeFractionPerHour(history.toList())?.takeIf { plausibleForModel(it, spec) }
} ?: continue
var chargeBands = existing.chargeBands
var listeningRates = existing.listeningRates
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] = stored?.fractionPerHour
tracker.sessionBaselineCounts[key] = stored?.updateCount ?: 0
// Blend against the rate stored when this session began, captured once per key, 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.
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
}
val learned = DrainProfile.LearnedRate(
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate),
sampleCount = history.size,
updateCount = (tracker.sessionBaselineCounts[key] ?: 0) + 1,
return DrainProfile.LearnedRate(
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
sampleCount = samples,
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 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(learned.fractionPerHour)}/hr" }
}
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
val last = tracker.lastPersistAtMs[cadenceKey]
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
tracker.lastPersistAtMs[cadenceKey] = nowMs
return true
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
if (history.direction == SlotHistory.Direction.CHARGE) {
// Whole-session scalar — the fallback basis and the stall-threshold reference.
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
val key = chargeRateKey(slot)
if (cadenceOk(key)) {
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
// Per-band rates — charging is CC/CV, each regime learns its own speed.
for (band in DrainModel.ChargeBand.entries) {
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
val key = "${chargeRateKey(slot)}/${band.name}"
if (!cadenceOk(key)) continue
val stored = chargeBands[slot.name]?.get(band.name)
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
chargeBands = chargeBands + (slot.name to slotBands)
changed = true
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
} else {
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
DrainModel.slopeFractionPerHour(history.toList())
?.takeIf { plausibleForModel(it, spec) }
?.let { fit ->
val key = rateKey(bucket, slot)
if (cadenceOk(key)) {
rates = rates + (key to blended(key, rates[key], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
// Listening rates (health basis): a just-closed segment persists immediately — it would
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
val pending = tracker.pendingListeningFits.remove(slot)
val (fit, samples) = when {
pending != null -> pending
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
}
if (fit != null && plausibleForModel(fit, spec)) {
val key = rateKey(bucket, slot)
val cadenceKey = "LISTEN/$key"
if (cadenceOk(cadenceKey, always = pending != null)) {
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
changed = true
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
if (changed) {
drainStore.save(
profileId,
existing.copy(model = device.model.name, rates = rates, chargeRates = chargeRates),
existing.copy(
model = device.model.name,
rates = rates,
chargeRates = chargeRates,
chargeBands = chargeBands,
listeningRates = listeningRates,
),
)
}
}
@@ -10,12 +10,12 @@ import kotlin.math.roundToInt
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
*
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Within a pod, the MEDIAN of its
* qualifying learned rates is used rather than the best or worst: sessions where the pod idled
* (in-ear, nothing playing) drain slower than the listening rating and would pull a "best" pick to a
* meaningless 100%, while call-heavy or cold sessions drain faster and would drag a "worst" pick
* into false doom. The median lands between both confounds. It remains an estimate — label it as
* such in the UI.
* 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.
*/
object BatteryHealth {
@@ -43,7 +43,7 @@ object BatteryHealth {
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.rates.mapNotNull { (key, rate) ->
val ratios = 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.
val parts = key.split('/')
@@ -59,11 +59,17 @@ object DrainModel {
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.
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
* tail, so suppression only covers the last sliver where the firmware is about to flip the
* charging flag off at 100% anyway.
*/
const val NEAR_FULL_SUPPRESS = 0.97f
const val NEAR_FULL_SUPPRESS = 0.99f
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
const val MIN_SAMPLES_CHARGE_NARROW = 2
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
const val MIN_BAND_RISE = 0.05f
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
@@ -112,15 +118,62 @@ object DrainModel {
}
/**
* 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.
* The three regimes of a lithium charge. Constant-current bulk is fast and roughly linear;
* above ~80% the charger switches to constant-voltage and the intake tapers, ending in a slow
* trickle. One linear rate over-promises badly above 80%, so each band learns its own rate.
*
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
* bands from them needs a haircut. Applied ONLY to the spec seed — measured or learned rates
* already reflect where they were observed.
*/
fun minutesUntilFull(levelFraction: Float, chargeFractionPerHour: Float): Int? {
if (chargeFractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction < 0f) return null
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
BULK(0.0f, 0.8f, 1.0f),
TAPER(0.8f, 0.9f, 0.5f),
TRICKLE(0.9f, 1.0f, 0.3f),
}
/**
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
* the wide bulk band keeps the regular sample requirement.
*/
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
val recent = samples.filter {
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
}
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
val floor = CHARGE_RATE_MIN * band.specMultiplier
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
}
/**
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
* usable rate, or the result is implausible.
*/
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
if (!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 }
var totalMinutes = 0.0
for (band in ChargeBand.entries) {
val start = maxOf(levelFraction, band.from)
val missing = band.to - start
if (missing <= 0f) continue
val rate = rateForBand(band) ?: return null
if (rate <= 0f) return null
totalMinutes += missing / rate * 60.0
}
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
}
/**
@@ -23,6 +23,19 @@ 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(),
/**
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
*/
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
/**
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
* figures, so the battery-health estimate compares against these; the general [rates]
* (which include idle wear) keep powering the time-remaining estimate.
*/
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
) {
@Serializable
data class LearnedRate(
@@ -558,7 +558,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
@@ -585,7 +585,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
@@ -6,6 +6,7 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -36,6 +37,8 @@ class BatteryEstimatorTest : BaseTest() {
optimized: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
worn: Boolean = false,
systemConnected: Boolean = false,
): PodDevice {
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -46,12 +49,21 @@ class BatteryEstimatorTest : BaseTest() {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
}
val settings = if (worn) {
mapOf<kotlin.reflect.KClass<out AapSetting>, AapSetting>(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)
)
} else emptyMap()
return PodDevice(
profileId = profileId,
ble = null,
aap = AapPodState(batteries = batteries),
aap = AapPodState(batteries = batteries, settings = settings),
profileModel = model,
batteryEstimateEnabled = estimateEnabled,
isSystemConnected = systemConnected,
)
}
@@ -59,6 +71,7 @@ class BatteryEstimatorTest : BaseTest() {
emissions: List<List<PodDevice>>,
stored: Map<String, DrainProfile> = emptyMap(),
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
musicActive: List<Boolean> = List(emissions.size) { false },
): BatteryEstimator {
val deviceMonitor = mockk<DeviceMonitor> {
every { devices } returns flowOf(*emissions.toTypedArray())
@@ -71,7 +84,10 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returnsMany clockMs
every { now() } returns now
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> {
every { isMusicActive } returnsMany musicActive
}
return BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
}
/**
@@ -270,11 +286,36 @@ class BatteryEstimatorTest : BaseTest() {
@Test
fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) {
// Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim
// (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min.
// (2.0/hr for a Pro 2) seeds the bands with the taper haircut: 30% of bulk at 2.0/hr (9m)
// + taper at 1.0/hr (6m) + trickle at 0.6/hr (10m) == 25 min.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 25
}
@Test
fun `learned band rates shape the ETA through the taper`() = runTest(UnconfinedTestDispatcher()) {
// At 85% the linear scalar (1.2/hr) would claim 8m; the learned bands know the taper is
// slower: 5% of taper at 1.0/hr (3m) + trickle at 0.6/hr (10m) == 13m.
val bands = mapOf(
"BULK" to learned(2.0f),
"TAPER" to learned(1.0f),
"TRICKLE" to learned(0.6f),
)
val stored = mapOf(
"p1" to DrainProfile(
chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)),
chargeBands = mapOf("LEFT" to bands, "RIGHT" to bands),
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.85f, right = 0.85f, charging = true, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 13
}
@Test
@@ -345,7 +386,7 @@ class BatteryEstimatorTest : BaseTest() {
}
@Test
fun `charge rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
fun `charge rates and band rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
@@ -359,12 +400,122 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
estimator.monitor().collect {}
coVerify {
drainStore.save("p1", match { it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") })
drainStore.save("p1", match {
it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") &&
it.chargeBands["LEFT"]?.containsKey("BULK") == true
})
}
}
@Test
fun `worn playing segments feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Steady discharge while worn, playing, and system-connected: learned into BOTH the
// general rates and the health-grade listening rates.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
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 audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match {
it.listeningRates.containsKey("UNKNOWN/LEFT") && it.rates.containsKey("UNKNOWN/LEFT")
})
}
}
@Test
fun `idle wear does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Worn and connected but nothing playing: general rates learn, listening rates stay empty.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
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 audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
}
}
@Test
fun `playback on another sink does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
// Music is playing but this device is NOT the system's audio sink (phone speaker, car):
// treating it as pod listening would poison health.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, worn = true, systemConnected = false))
}
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
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 audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
}
}
@Test
fun `a listening segment is flushed when playback stops`() = runTest(UnconfinedTestDispatcher()) {
// 1-minute cadence: fit persists at the 4th sample (t=3), further drops are inside the
// persistence cooldown — then playback stops. The closed segment must be flushed and
// persisted anyway, not silently discarded with the cleared window.
val worn = (0 until 5).map { i ->
listOf(device("p1", left = 0.80f - i * 0.01f, right = 0.80f - i * 0.01f, worn = true, systemConnected = true))
}
val after = listOf(listOf(device("p1", left = 0.75f, right = 0.75f, worn = true, systemConnected = true)))
val emissions = worn + after
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> {
every { isMusicActive } returnsMany listOf(true, true, true, true, true, false)
}
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
// Two listening persists: the cadence one mid-segment, and the forced flush at gate-off.
coVerify(atLeast = 2) {
drainStore.save("p1", match { it.listeningRates.containsKey("UNKNOWN/LEFT") })
}
}
@@ -417,7 +568,8 @@ class BatteryEstimatorTest : BaseTest() {
every { elapsedRealtime() } returns 0L
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
estimator.reset("p1")
@@ -21,7 +21,7 @@ class BatteryHealthTest : BaseTest() {
@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)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
}
@@ -30,7 +30,7 @@ class BatteryHealthTest : BaseTest() {
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
// diverge — each pod gets its own figure instead of one masking the other.
val profile = DrainProfile(
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
@@ -44,7 +44,7 @@ class BatteryHealthTest : BaseTest() {
@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)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
}
@@ -54,7 +54,7 @@ class BatteryHealthTest : BaseTest() {
// so a single gentle idle session can't inflate the figure and one hard session can't
// tank it.
val profile = DrainProfile(
rates = mapOf(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 6f),
"ON/LEFT" to rate(1f / 3f),
"OFF/LEFT" to rate(1f / 1.5f),
@@ -66,14 +66,14 @@ class BatteryHealthTest : BaseTest() {
@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))
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@Test
fun `models without a rating have no health`() {
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
}
@@ -87,7 +87,7 @@ class BatteryHealthTest : BaseTest() {
fun `rates learned on different hardware are ignored`() {
val profile = DrainProfile(
model = PodModel.AIRPODS_PRO.name,
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
}
@@ -95,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
@Test
fun `malformed bucket keys and broken rates are skipped`() {
val profile = DrainProfile(
rates = mapOf(
listeningRates = 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
@@ -112,14 +112,14 @@ class BatteryHealthTest : BaseTest() {
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)))
val profile = DrainProfile(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
val profile = DrainProfile(rates = mapOf("ON/HEADSET" to rate(0.1f)))
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
health.left shouldBe null
@@ -165,18 +165,74 @@ class DrainModelTest : BaseTest() {
@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
// Uniform 1.2/hr across all bands: 40% missing -> 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()
fun `minutesUntilFull walks the remaining bands at their own rates`() {
// At 85%: 5% of taper at 1.0/hr (3m) + 10% of trickle at 0.6/hr (10m) = 13m. The bulk
// band is already behind and must not contribute.
val rates = mapOf(
DrainModel.ChargeBand.BULK to 2.0f,
DrainModel.ChargeBand.TAPER to 1.0f,
DrainModel.ChargeBand.TRICKLE to 0.6f,
)
DrainModel.minutesUntilFull(0.85f) { rates[it] } shouldBe 13
}
@Test
fun `minutesUntilFull needs a rate for every remaining band`() {
// Bulk known but the taper band has no basis -> no honest ETA.
DrainModel.minutesUntilFull(0.50f) { band ->
if (band == DrainModel.ChargeBand.BULK) 2.0f else null
}.shouldBeNull()
}
@Test
fun `minutesUntilFull suppresses the near-full sliver`() {
DrainModel.minutesUntilFull(0.995f) { 1.2f }.shouldBeNull()
}
@Test
fun `minutesUntilFull rejects a non-positive rate`() {
DrainModel.minutesUntilFull(0.60f, 0f).shouldBeNull()
DrainModel.minutesUntilFull(0.60f) { 0f }.shouldBeNull()
}
@Test
fun `band fits only use samples inside the band`() {
// Bulk samples rise fast (2.4/hr), then the taper crawls: two in-band taper points
// 24 minutes apart -> 0.25/hr... below the taper floor? floor = 0.25 * 0.5 = 0.125, ok.
val samples = listOf(
DrainSample(0L, 0.60f),
DrainSample(5 * 60_000L, 0.70f),
DrainSample(10 * 60_000L, 0.80f),
DrainSample(34 * 60_000L, 0.90f),
)
val taper = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.TAPER)
taper.shouldNotBeNull()
taper shouldBe (0.25f plusOrMinus 0.01f)
// The bulk fit must not be dragged down by the slow taper points beyond its range.
val bulk = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.BULK)
bulk.shouldNotBeNull()
(bulk > 1.0f) shouldBe true
}
@Test
fun `a narrow band accepts a two-point fit but the bulk band does not`() {
val twoTaperPoints = listOf(
DrainSample(0L, 0.80f),
DrainSample(12 * 60_000L, 0.90f), // 0.5/hr
)
DrainModel.chargeBandSlopeFractionPerHour(twoTaperPoints, DrainModel.ChargeBand.TAPER)
.shouldNotBeNull()
val twoBulkPoints = listOf(
DrainSample(0L, 0.40f),
DrainSample(12 * 60_000L, 0.50f),
)
DrainModel.chargeBandSlopeFractionPerHour(twoBulkPoints, DrainModel.ChargeBand.BULK)
.shouldBeNull()
}
@Test
@@ -28,6 +28,8 @@ class DrainProfileSerializationTest : BaseTest() {
profile.model shouldBe null
profile.chargeRates shouldBe emptyMap()
profile.chargeBands shouldBe emptyMap()
profile.listeningRates shouldBe emptyMap()
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
}
@@ -51,6 +53,24 @@ class DrainProfileSerializationTest : BaseTest() {
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
chargeBands = mapOf(
"LEFT" to mapOf(
"TAPER" to DrainProfile.LearnedRate(
fractionPerHour = 0.9f,
sampleCount = 3,
updateCount = 2,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
)
),
listeningRates = mapOf(
"ON/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 0.24f,
sampleCount = 7,
updateCount = 3,
updatedAt = Instant.ofEpochMilli(1700000000000L),
)
),
)
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile