From afba33dd83d2d9eeb21413d39544e29e1ca4f6dc Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 11:16:00 +0200 Subject: [PATCH] feat(battery): Model charge taper per band, base health on listening-only drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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" --- .../monitor/core/battery/BatteryEstimator.kt | 227 ++++++++++++++---- .../monitor/core/battery/BatteryHealth.kt | 14 +- .../capod/monitor/core/battery/DrainModel.kt | 75 +++++- .../monitor/core/battery/DrainProfile.kt | 13 + .../DeviceSettingsViewModelTest.kt | 4 +- .../core/battery/BatteryEstimatorTest.kt | 168 ++++++++++++- .../monitor/core/battery/BatteryHealthTest.kt | 20 +- .../monitor/core/battery/DrainModelTest.kt | 66 ++++- .../battery/DrainProfileSerializationTest.kt | 20 ++ 9 files changed, 520 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt index 8d3ca667..433dfa29 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt @@ -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>(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.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.entries.associateWith { SlotHistory() } + + /** Fit + sample count of a just-closed listening segment, persisted on the next pass. */ + val pendingListeningFits: MutableMap> = mutableMapOf() + /** Smoothed displayed minutes, per pod. */ val lastMinutes: MutableMap = 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, + ), ) } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt index 5e4d3f8e..ee745e71 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt @@ -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 "/" — anything else is corrupted or // future-format data and must not feed a health figure. val parts = key.split('/') diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt index 7828e304..0fffb7ef 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt @@ -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, 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 } } /** diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt index 96cd270b..9f30417f 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt @@ -23,6 +23,19 @@ data class DrainProfile( @SerialName("model") val model: String? = null, @SerialName("rates") val rates: Map = emptyMap(), @SerialName("chargeRates") val chargeRates: Map = emptyMap(), + /** + * Per-band charge rates, `"" -> "" -> 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> = emptyMap(), + /** + * Drain rates learned ONLY while the pod was worn and audio was actually playing on this + * device — same `"/"` 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 = emptyMap(), ) { @Serializable data class LearnedRate( diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index f0ae7f33..b75f3eeb 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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, diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt index f9336be1..dafbca54 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt @@ -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, 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>, stored: Map = emptyMap(), clockMs: List = List(emissions.size) { it * 4 * 60_000L }, + musicActive: List = List(emissions.size) { false }, ): BatteryEstimator { val deviceMonitor = mockk { 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 { + 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 { 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 { 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 { + every { profiles } returns MutableStateFlow(emptyMap()) + coEvery { save(any(), any()) } returns Unit + } + val deviceMonitor = mockk { every { devices } returns flowOf(*emissions.toTypedArray()) } + val timeSource = mockk { + every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L } + every { now() } returns now + } + val audioManager = mockk { 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 { + every { profiles } returns MutableStateFlow(emptyMap()) + coEvery { save(any(), any()) } returns Unit + } + val deviceMonitor = mockk { every { devices } returns flowOf(*emissions.toTypedArray()) } + val timeSource = mockk { + every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L } + every { now() } returns now + } + val audioManager = mockk { 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 { + every { profiles } returns MutableStateFlow(emptyMap()) + coEvery { save(any(), any()) } returns Unit + } + val deviceMonitor = mockk { every { devices } returns flowOf(*emissions.toTypedArray()) } + val timeSource = mockk { + every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L } + every { now() } returns now + } + val audioManager = mockk { 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 { + every { profiles } returns MutableStateFlow(emptyMap()) + coEvery { save(any(), any()) } returns Unit + } + val deviceMonitor = mockk { every { devices } returns flowOf(*emissions.toTypedArray()) } + val timeSource = mockk { + every { elapsedRealtime() } returnsMany emissions.indices.map { it * 60_000L } + every { now() } returns now + } + val audioManager = mockk { + 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 { every { isMusicActive } returns false } + val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager) estimator.reset("p1") diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt index fd060623..0aca9372 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt index 25852300..22983fcd 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt index 8042fb4c..e12cde2d 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt @@ -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(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile