From 177d5366f43387cf197d1b1da5ea4558c4796c51 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 02:28:43 +0200 Subject: [PATCH] feat(battery): Add time-until-charged and derived battery health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../ui/devicesettings/DeviceSettingsScreen.kt | 3 + .../devicesettings/DeviceSettingsViewModel.kt | 15 ++ .../cards/DeviceInfoDetailItems.kt | 10 +- .../main/ui/overview/cards/DualPodsCard.kt | 15 +- .../main/ui/overview/cards/SinglePodsCard.kt | 2 +- .../monitor/core/battery/BatteryEstimate.kt | 4 + .../monitor/core/battery/BatteryEstimator.kt | 211 +++++++++++++++--- .../monitor/core/battery/BatteryHealth.kt | 70 ++++++ .../capod/monitor/core/battery/DrainModel.kt | 92 +++++++- .../monitor/core/battery/DrainProfile.kt | 33 ++- app/src/main/res/values/strings.xml | 3 + .../DeviceSettingsViewModelTest.kt | 74 ++++++ .../cards/DeviceInfoDetailItemsTest.kt | 54 +++-- .../core/battery/BatteryEstimatorTest.kt | 144 +++++++++++- .../monitor/core/battery/BatteryHealthTest.kt | 100 +++++++++ .../monitor/core/battery/DrainModelTest.kt | 72 ++++++ .../battery/DrainProfileSerializationTest.kt | 58 +++++ 17 files changed, 896 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 54a8f887..1e7bdf5c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index fe396368..4d4855d2 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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 val nudgeAvailability = args[7] as NudgeAvailability + + @Suppress("UNCHECKED_CAST") + val drainProfiles = args[8] as Map val appleProfile = profiles.filterIsInstance() .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() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt index 366127e6..40e95875 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt @@ -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 { - 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)) } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 9d094f05..24e95d13 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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( diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 17e9e59b..6a17447d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt index 42859b5c..b7be278a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt @@ -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 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 05b315b1..2a9ccb0a 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 @@ -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() 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 = mutableMapOf() var lastUpdateMs: Long? = null - // Keyed by "/". + /** When each slot's level last visibly ROSE while charging — drives stall suppression. */ + val lastRiseMs: MutableMap = mutableMapOf() + + // Keyed by "/" for drain rates, "CHARGE/" for charge rates. val lastPersistAtMs: MutableMap = 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 = mutableMapOf() + val sessionBaselineCounts: MutableMap = 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? { + 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 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 new file mode 100644 index 00000000..f0dae917 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt @@ -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 "/" 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 } + } +} 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 45c3f0e9..7828e304 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 @@ -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): 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): 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, minSamples: Int): List? { + 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): 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() } /** 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 603d60dc..96cd270b 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 @@ -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 `"/"`, 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 = emptyMap(), + @SerialName("chargeRates") val chargeRates: Map = 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" + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 600610fe..15bf100c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -319,6 +319,7 @@ %1$dh %2$dm %1$dh %1$dm + ⚡ %1$s Show notifications "Allow CAPod to show notifications about your AirPods, e.g. their current status while connected." @@ -476,6 +477,8 @@ Right Pod Serial Left Bonded Right Bonded + Battery health (estimated) + ~%1$d%% Device Details Show device details Status 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 31cecfc0..91857a1f 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 @@ -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> private lateinit var monitorModeResolver: MonitorModeResolver private lateinit var nudgeCapabilityStore: NudgeCapabilityStore private lateinit var nudgeAvailabilityFlow: MutableStateFlow @@ -119,6 +125,10 @@ class DeviceSettingsViewModelTest : BaseTest() { every { profiles } returns profilesFlow } batteryEstimator = mockk(relaxed = true) + drainProfilesFlow = MutableStateFlow(emptyMap()) + drainStore = mockk().also { + every { it.profiles } returns drainProfilesFlow + } effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) monitorModeResolver = mockk().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(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(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() diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt index 7de29409..20d0e47f 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt @@ -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 { 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 817a753e..3aa5ee20 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 @@ -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 { + 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 { every { devices } returns flowOf(*emissions.toTypedArray()) } + val timeSource = mockk { + 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 { 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 new file mode 100644 index 00000000..478841e6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt @@ -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 + } +} 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 1e4c5b51..25852300 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 @@ -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 = (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 + } } 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 new file mode 100644 index 00000000..8042fb4c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt @@ -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(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(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile + } +}