From d4dddb1f3864cc588a5fec3c6d845ce6010b7268 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 12:44:40 +0200 Subject: [PATCH] feat(battery): Add experimental case charge ETA and case battery health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show the time until the case is full inside its charging chip, learned from the case's own rising level with the existing charge-band model; no rating exists for case charging, so the first charge learns before it shows - Derive a case battery health from observed transfer efficiency: pod percent gained per case percent spent while docked and unplugged, corrected by each pod's own health, compared against Apple's "with charging case" totals - Case data is only genuine while a pod is docked — both transports silently freeze the last value otherwise. Battery updates now flag whether the case entry is live, and BLE gains strict same-frame case accessors, so estimates never learn from frozen echoes - The case deliberately gets no runtime estimate: idle-then-burst drain has no meaningful hourly rate - Mark the case metrics as experimental in the Battery settings card --- .../ui/devicesettings/DeviceSettingsScreen.kt | 3 + .../ui/devicesettings/cards/BatteryCard.kt | 9 + .../cards/DeviceInfoDetailItems.kt | 4 + .../main/ui/overview/cards/DualPodsCard.kt | 12 +- .../monitor/core/battery/BatteryEstimate.kt | 8 +- .../monitor/core/battery/BatteryEstimator.kt | 255 +++++++++++++++++- .../monitor/core/battery/BatteryHealth.kt | 21 +- .../monitor/core/battery/DrainProfile.kt | 17 ++ .../darken/capod/pods/core/apple/PodModel.kt | 25 +- .../capod/pods/core/apple/aap/AapPodState.kt | 7 + .../core/apple/aap/engine/AapSessionEngine.kt | 4 + .../core/apple/ble/devices/DualApplePods.kt | 20 ++ app/src/main/res/values/strings.xml | 2 + .../cards/DeviceInfoDetailItemsTest.kt | 1 + .../core/battery/BatteryEstimatorTest.kt | 97 ++++++- .../monitor/core/battery/BatteryHealthTest.kt | 28 ++ .../battery/DrainProfileSerializationTest.kt | 6 + .../pods/core/apple/ModelFeaturesTest.kt | 16 ++ 18 files changed, 515 insertions(+), 20 deletions(-) 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 0179624f..4ef0dfca 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 @@ -307,6 +307,9 @@ fun DeviceSettingsScreen( headset = state.batteryHealth.headset?.let { stringResource(R.string.device_settings_info_battery_health_value, it) }, + case = state.batteryHealth.case?.let { + stringResource(R.string.device_settings_info_battery_health_value, it) + }, ) state.batteryHealthPending -> BatteryHealthTexts( pending = stringResource(R.string.device_settings_info_battery_health_pending), diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt index d8ed57e8..f77ff3f8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.unit.dp import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.common.settings.InfoBoxType import eu.darken.capod.common.settings.SettingsBaseItem +import eu.darken.capod.common.settings.SettingsInfoBox import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem @@ -88,6 +90,13 @@ internal fun BatteryCard( subtitle = stringResource(R.string.device_battery_estimate_reset_desc), onClick = { showResetConfirm = true }, ) + if (features.hasCase) { + SettingsInfoBox( + title = stringResource(R.string.device_settings_experimental_title), + text = stringResource(R.string.device_battery_case_experimental_note), + type = InfoBoxType.INFO, + ) + } // Same divider style the sibling device-settings cards use (see ReactionsCard). HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), 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 69a597fb..ac751e66 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 @@ -21,6 +21,7 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels( batteryHealth = stringResource(R.string.device_settings_info_battery_health_label), leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label), rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label), + caseBatteryHealth = stringResource(R.string.device_settings_info_battery_health_case_label), ) internal data class DeviceInfoDetailLabels( @@ -37,6 +38,7 @@ internal data class DeviceInfoDetailLabels( val batteryHealth: String, val leftBatteryHealth: String, val rightBatteryHealth: String, + val caseBatteryHealth: String, ) /** @@ -48,6 +50,7 @@ internal data class BatteryHealthTexts( val left: String? = null, val right: String? = null, val headset: String? = null, + val case: String? = null, val pending: String? = null, ) @@ -65,6 +68,7 @@ private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLab health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right)) } health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) } + health.case?.let { add(DeviceDetailItem.Single(labels.caseBatteryHealth, it)) } if (isEmpty() && health.pending != null) { add(DeviceDetailItem.Single(labels.batteryHealth, health.pending)) } 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 10262103..9b1365b5 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 @@ -265,7 +265,11 @@ private fun ColumnScope.DualPodsCardExpanded( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), ) - CaseRow(device = device) + CaseRow( + device = device, + untilCharged = batteryEstimate?.caseMinutesUntilCharged + ?.let { formatBatteryDurationShort(context, it) }, + ) } } } @@ -412,6 +416,7 @@ private fun PodGauge( @Composable private fun CaseRow( device: PodDevice, + untilCharged: String? = null, ) { val context = LocalContext.current @@ -454,7 +459,10 @@ private fun CaseRow( ) AapPodState.ChargingState.CHARGING -> StatusChip( icon = Icons.TwoTone.BatteryChargingFull, - label = stringResource(R.string.pods_charging_label), + // Same chip-borne ETA pattern as the pod gauges. + label = untilCharged + ?.let { "${stringResource(R.string.pods_charging_label)} · $it" } + ?: stringResource(R.string.pods_charging_label), ) else -> Unit } 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 6034d471..ec5f8a04 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 @@ -9,6 +9,12 @@ data class BatteryEstimate( val left: Pod? = null, val right: Pod? = null, val headset: Pod? = null, + /** + * Minutes until the CASE is full — non-null only while it is actively charging with a usable + * learned/measured rate. The case gets no runtime estimate: its idle-then-burst drain has no + * meaningful hourly rate. + */ + val caseMinutesUntilCharged: Int? = null, ) { /** * @property minutesRemaining smoothed estimate of minutes until this pod empties @@ -43,5 +49,5 @@ data class BatteryEstimate( LIVE, } - val hasAny: Boolean get() = left != null || right != null || headset != null + val hasAny: Boolean get() = left != null || right != null || headset != null || caseMinutesUntilCharged != null } 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 433dfa29..1cfe9668 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 @@ -12,6 +12,7 @@ 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 +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual import eu.darken.capod.pods.core.apple.ble.isKnownBattery @@ -105,6 +106,16 @@ class BatteryEstimator @Inject constructor( /** Fit + sample count of a just-closed listening segment, persisted on the next pass. */ val pendingListeningFits: MutableMap> = mutableMapOf() + /** CASE charge window — kept out of the pod slot maps (different gating, no drain side). */ + val caseHistory: SlotHistory = SlotHistory() + var caseLastRiseMs: Long? = null + + /** Open docked-discharge window for the case transfer observation. */ + var transferWindow: TransferWindow? = null + + /** A closed window's health-corrected transfer ratio, persisted on the next pass. */ + var pendingTransferRatio: Float? = null + /** Smoothed displayed minutes, per pod. */ val lastMinutes: MutableMap = mutableMapOf() var lastUpdateMs: Long? = null @@ -130,6 +141,10 @@ class BatteryEstimator @Inject constructor( clearSlots() listeningSlots.values.forEach { it.clear() } pendingListeningFits.clear() + caseHistory.clear() + caseLastRiseMs = null + transferWindow = null + pendingTransferRatio = null lastMinutes.clear() lastRiseMs.clear() sessionBaseline.clear() @@ -137,6 +152,15 @@ class BatteryEstimator @Inject constructor( } } + /** Snapshot state for one open case transfer window (docked pods drawing from the case). */ + private class TransferWindow( + val caseStart: Float, + var lastCase: Float, + /** Fraction gained per pod — kept per slot so each pod's own health can correct its share. */ + val slotGains: MutableMap = mutableMapOf(), + val lastPodFractions: MutableMap, + ) + private val trackers = mutableMapOf() // Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate. @@ -305,6 +329,8 @@ class BatteryEstimator @Inject constructor( } } + updateCaseTracker(profileId, tracker, device, nowMs) + persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false) return computeEstimate(profileId, tracker, device, bucket, nowMs) } @@ -321,6 +347,7 @@ class BatteryEstimator @Inject constructor( 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), + caseMinutesUntilCharged = caseChargeEstimate(profileId, tracker, device, nowMs), ) return estimate.takeIf { it.hasAny } } @@ -396,7 +423,7 @@ class BatteryEstimator @Inject constructor( val history = tracker.slots.getValue(slot) 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 learnedScalar = learnedChargeRate(profileId, device, slot.name) val specRate = device.model.batterySpec?.chargeFractionPerHour // Per band: this session's in-band fit, then the learned band rate, then the scalar @@ -404,7 +431,7 @@ class BatteryEstimator @Inject constructor( // 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) + ?: learnedChargeBand(profileId, device, slot.name, band) ?: liveScalar ?: learnedScalar ?: specRate?.let { it * band.specMultiplier } @@ -422,9 +449,177 @@ class BatteryEstimator @Inject constructor( private fun learnedChargeBand( profileId: ProfileId, device: PodDevice, - slot: Slot, + slotName: String, band: DrainModel.ChargeBand, - ): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour + ): Float? = storedProfileFor(profileId, device)?.chargeBands[slotName]?.get(band.name)?.fractionPerHour + + // ---- CASE (experimental) ---- + // The case only reports genuine data while a pod is docked, and its discharge is idle-then-burst + // (recharging pods) — so it gets a charge ETA and a transfer-efficiency health, never an hourly + // drain rate. + + private fun updateCaseTracker(profileId: ProfileId, tracker: DeviceTracker, device: PodDevice, nowMs: Long) { + val reading = device.caseReading() + val charging = device.caseCharging() + val history = tracker.caseHistory + + when { + reading == null || charging != true -> { // no LIVE data, or not charging → no charge window + history.clear() + tracker.caseLastRiseMs = null + } + else -> { + val (fraction, source) = reading + history.realign(SlotHistory.Direction.CHARGE, source) + val last = history.lastFraction + when { + last == null -> { + history.record(DrainSample(nowMs, fraction)) + tracker.caseLastRiseMs = nowMs + } + fraction > last + EPSILON -> { + history.record(DrainSample(nowMs, fraction)) + tracker.caseLastRiseMs = nowMs + } + fraction < last - EPSILON -> { // dropped while charging → restart window + history.clear() + history.record(DrainSample(nowMs, fraction)) + tracker.caseLastRiseMs = nowMs + } + else -> Unit + } + } + } + + updateTransferWindow(profileId, tracker, device) + } + + /** + * Tracks a docked-discharge window: the case (live via AAP only — 1% granularity; BLE's 10% + * steps are too coarse for a health-grade ratio) spending its battery into charging pods while + * unplugged. On close, the observed transfer ratio (summed pod fraction gained per case + * fraction spent) is queued for persistence — the basis of the case battery health. + */ + private fun updateTransferWindow(profileId: ProfileId, tracker: DeviceTracker, device: PodDevice) { + val aap = device.aap?.takeIf { it.caseIsLive } + val caseFraction = aap?.batteryCase?.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f) + val caseCharging = aap?.caseChargingState == AapPodState.ChargingState.CHARGING + // Pod inputs come from the SAME AAP state as the case — never the BLE fallback, whose 10% + // steps would inject coarse jumps into a health-grade ratio. + fun podFraction(slot: Slot): Float? = when (slot) { + Slot.LEFT -> aap?.batteryLeft + Slot.RIGHT -> aap?.batteryRight + else -> null + }?.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f) + + val chargingPods = listOf(Slot.LEFT, Slot.RIGHT).filter { slot -> + when (slot) { + Slot.LEFT -> aap?.isLeftCharging == true + Slot.RIGHT -> aap?.isRightCharging == true + else -> false + } + } + + if (caseFraction == null || caseCharging || chargingPods.isEmpty()) { + closeTransferWindow(profileId, tracker, device) + return + } + val window = tracker.transferWindow + if (window == null) { + tracker.transferWindow = TransferWindow( + caseStart = caseFraction, + lastCase = caseFraction, + lastPodFractions = chargingPods + .mapNotNull { slot -> podFraction(slot)?.let { slot to it } } + .toMap(mutableMapOf()), + ) + return + } + if (caseFraction > window.lastCase + EPSILON) { // case rose while "unplugged" → untrustworthy + tracker.transferWindow = null + return + } + window.lastCase = caseFraction + for (slot in chargingPods) { + val fraction = podFraction(slot) ?: continue + val previous = window.lastPodFractions[slot] + when { + previous == null -> Unit // pod joined mid-window; gains count from here on + fraction < previous - EPSILON -> { // pod level dropped mid-charge (reseat) → discard + tracker.transferWindow = null + return + } + fraction > previous + EPSILON -> + window.slotGains[slot] = (window.slotGains[slot] ?: 0f) + (fraction - previous) + } + window.lastPodFractions[slot] = fraction + } + } + + private fun closeTransferWindow(profileId: ProfileId, tracker: DeviceTracker, device: PodDevice) { + val window = tracker.transferWindow ?: return + tracker.transferWindow = null + val caseDrop = window.caseStart - window.lastCase + val totalGain = window.slotGains.values.sum() + if (caseDrop < MIN_TRANSFER_CASE_DROP || totalGain <= 0f) return + + // Degraded pods gain percent FASTER than healthy ones (less capacity behind each percent), + // which would flatter the case — scale each pod's own share by ITS health when known. + val podHealth = BatteryHealth.estimate(storedProfileFor(profileId, device), device.model) + val correctedGain = window.slotGains.entries.sumOf { (slot, gain) -> + val health = when (slot) { + Slot.LEFT -> podHealth?.left + Slot.RIGHT -> podHealth?.right + else -> null + } + (gain * ((health ?: 100) / 100f)).toDouble() + }.toFloat() + + val ratio = correctedGain / caseDrop + tracker.pendingTransferRatio = ratio + .takeIf { it.isFinite() && it in TRANSFER_RATIO_MIN..TRANSFER_RATIO_MAX } + log(TAG, VERBOSE) { "Transfer window closed: drop=$caseDrop gain=$totalGain ratio=$ratio" } + } + + /** Minutes until the case is full — live/learned rates only; Apple publishes no case charge spec. */ + private fun caseChargeEstimate(profileId: ProfileId, tracker: DeviceTracker, device: PodDevice, nowMs: Long): Int? { + if (device.caseCharging() != true) return null + val (fraction, source) = device.caseReading() ?: return null + val history = tracker.caseHistory + 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, CASE_KEY) + + fun rateFor(band: DrainModel.ChargeBand): Float? = + (if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null) + ?: learnedChargeBand(profileId, device, CASE_KEY, band) + ?: liveScalar + ?: learnedScalar + + val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to } + ?: DrainModel.ChargeBand.TRICKLE + val stallRate = rateFor(currentBand) ?: return null + val lastRise = tracker.caseLastRiseMs ?: return null + val step = if (source == DataSource.AAP) STEP_AAP else STEP_BLE + if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null + + return DrainModel.minutesUntilFull(fraction, ::rateFor) + } + + /** LIVE case reading only — both transports silently freeze the last value once pods undock. */ + private fun PodDevice.caseReading(): Pair? { + val aapValue = aap?.takeIf { it.caseIsLive }?.batteryCase + val (value, source) = when { + aapValue != null -> aapValue to DataSource.AAP + else -> (ble as? DualApplePods)?.batteryCaseLivePercent?.let { it to DataSource.BLE } + ?: return null + } + return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source } + } + + private fun PodDevice.caseCharging(): Boolean? = + aap?.takeIf { it.caseIsLive }?.let { it.caseChargingState == AapPodState.ChargingState.CHARGING } + ?: (ble as? DualApplePods)?.isCaseChargingLive /** * Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next @@ -553,6 +748,43 @@ class BatteryEstimator @Inject constructor( } } + // CASE: charge fits from the dedicated case window, plus a closed transfer observation. + var caseTransfer = existing.caseTransfer + if (tracker.caseHistory.direction == SlotHistory.Direction.CHARGE) { + DrainModel.chargeSlopeFractionPerHour(tracker.caseHistory.toList())?.let { fit -> + val key = "CHARGE/$CASE_KEY" + if (cadenceOk(key)) { + chargeRates = chargeRates + (CASE_KEY to blended(key, chargeRates[CASE_KEY], fit, tracker.caseHistory.size)) + changed = true + log(TAG, VERBOSE) { "Persisting case charge rate for $profileId: ${"%.3f".format(fit)}/hr" } + } + } + for (band in DrainModel.ChargeBand.entries) { + val fit = DrainModel.chargeBandSlopeFractionPerHour(tracker.caseHistory.toList(), band) ?: continue + val key = "CHARGE/$CASE_KEY/${band.name}" + if (!cadenceOk(key)) continue + val stored = chargeBands[CASE_KEY]?.get(band.name) + chargeBands = chargeBands + + (CASE_KEY to (chargeBands[CASE_KEY].orEmpty() + (band.name to blended(key, stored, fit, tracker.caseHistory.size)))) + changed = true + } + } + tracker.pendingTransferRatio?.let { observed -> + tracker.pendingTransferRatio = null + val key = "TRANSFER" + if (!tracker.sessionBaseline.containsKey(key)) { + tracker.sessionBaseline[key] = existing.caseTransfer?.ratio + tracker.sessionBaselineCounts[key] = existing.caseTransfer?.updateCount ?: 0 + } + caseTransfer = DrainProfile.TransferRatio( + ratio = DrainModel.blendRate(tracker.sessionBaseline[key], observed), + updateCount = (tracker.sessionBaselineCounts[key] ?: 0) + 1, + updatedAt = timeSource.now(), + ) + changed = true + log(TAG, VERBOSE) { "Persisting case transfer ratio for $profileId: ${"%.2f".format(observed)}" } + } + if (changed) { drainStore.save( profileId, @@ -562,6 +794,7 @@ class BatteryEstimator @Inject constructor( chargeRates = chargeRates, chargeBands = chargeBands, listeningRates = listeningRates, + caseTransfer = caseTransfer, ), ) } @@ -572,8 +805,8 @@ class BatteryEstimator @Inject constructor( 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 + private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slotName: String): Float? = + storedProfileFor(profileId, device)?.chargeRates[slotName]?.fractionPerHour /** The stored profile, ignored entirely when its rates were learned on different hardware. */ private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? = @@ -663,6 +896,16 @@ class BatteryEstimator @Inject constructor( private const val STEP_AAP = 0.01f private const val STEP_BLE = 0.10f + /** Persistence key for the case's charge rates/bands (the pod slots use their enum names). */ + private const val CASE_KEY = "CASE" + + /** A transfer window must see at least this much case drop before its ratio is trusted. */ + private const val MIN_TRANSFER_CASE_DROP = 0.05f + + /** Plausibility band for a transfer ratio (nominal is ~4-10 depending on model). */ + private const val TRANSFER_RATIO_MIN = 0.5f + private const val TRANSFER_RATIO_MAX = 20f + /** 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 index ee745e71..64bf34b8 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 @@ -26,8 +26,10 @@ object BatteryHealth { val left: Int? = null, val right: Int? = null, val headset: Int? = null, + /** Derived from observed case-to-pod transfer efficiency, not from a drain rate. */ + val case: Int? = null, ) { - val hasAny: Boolean get() = left != null || right != null || headset != null + val hasAny: Boolean get() = left != null || right != null || headset != null || case != null } fun estimate(profile: DrainProfile?, model: PodModel): PerPod? { @@ -39,9 +41,26 @@ object BatteryHealth { left = slotPercent(profile, spec, "LEFT"), right = slotPercent(profile, spec, "RIGHT"), headset = slotPercent(profile, spec, "HEADSET"), + case = casePercent(profile, spec), ).takeIf { it.hasAny } } + /** + * Case health = observed transfer efficiency vs the nominal ratio Apple's figures pin: a full + * case nominally delivers `(withCase - single) / single` full recharges of BOTH pods, i.e. a + * summed pod-fraction gain of twice that per case fraction. A worn case delivers fewer. + */ + private fun casePercent(profile: DrainProfile, spec: PodModel.BatterySpec): Int? { + val transfer = profile.caseTransfer ?: return null + if (transfer.updateCount < MIN_UPDATE_COUNT) return null + if (!transfer.ratio.isFinite() || transfer.ratio <= 0f) return null + val hours = spec.listeningHoursAncOn ?: spec.listeningHoursAncOff ?: return null + val withCase = spec.listeningHoursWithCase ?: return null + val nominal = (withCase - hours) / hours * 2f + if (nominal <= 0f || !nominal.isFinite()) return null + return (transfer.ratio / nominal * 100f).roundToInt().coerceIn(1, 100) + } + private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? { val ratios = profile.listeningRates.mapNotNull { (key, rate) -> // Keys must be exactly "/" — anything else is corrupted or 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 9f30417f..ec492191 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 @@ -36,6 +36,13 @@ data class DrainProfile( * (which include idle wear) keep powering the time-remaining estimate. */ @SerialName("listeningRates") val listeningRates: Map = emptyMap(), + /** + * Learned case transfer efficiency: summed pod battery-fraction gained per case fraction spent + * while docked and unplugged, corrected for current pod health (degraded pods gain % faster). + * Compared against the nominal ratio from Apple's "with charging case" totals to derive the + * case battery health. + */ + @SerialName("caseTransfer") val caseTransfer: TransferRatio? = null, ) { @Serializable data class LearnedRate( @@ -52,6 +59,16 @@ data class DrainProfile( @SerialName("updatedAt") val updatedAt: Instant, ) + @Serializable + data class TransferRatio( + /** Summed pod fraction gained per case fraction spent (health-corrected). */ + @SerialName("ratio") val ratio: Float, + /** How many observed docked-discharge sessions have blended into this ratio. */ + @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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index c2116316..ee7a96d1 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -27,7 +27,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f, listeningHoursWithCase = 24f), modelNumbers = setOf("A1523", "A1722"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen1_left, rightPodIconRes = R.drawable.device_airpods_gen1_right, @@ -45,7 +45,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f, listeningHoursWithCase = 24f), modelNumbers = setOf("A2031", "A2032"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen1_left, rightPodIconRes = R.drawable.device_airpods_gen1_right, @@ -67,7 +67,7 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f), + batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f, listeningHoursWithCase = 30f), modelNumbers = setOf("A2564", "A2565"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, rightPodIconRes = R.drawable.device_airpods_gen3_right, @@ -90,7 +90,7 @@ enum class PodModel( hasEarDetectionToggle = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f, listeningHoursWithCase = 30f), modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, rightPodIconRes = R.drawable.device_airpods_gen3_right, @@ -122,7 +122,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), + batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f, listeningHoursWithCase = 20f), modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones leftPodIconRes = R.drawable.device_airpods_gen4anc_left, rightPodIconRes = R.drawable.device_airpods_gen4anc_right, @@ -148,7 +148,7 @@ enum class PodModel( hasListeningModeCycle = true, hasAllowOffOption = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), + batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f, listeningHoursWithCase = 24f), modelNumbers = setOf("A2083", "A2084"), // L/R earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -182,7 +182,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f, listeningHoursWithCase = 30f), modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -216,7 +216,7 @@ enum class PodModel( hasStemConfig = true, hasSleepDetection = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f, listeningHoursWithCase = 30f), modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -251,7 +251,7 @@ enum class PodModel( hasSleepDetection = true, hasDynamicEndOfCharge = true, ), - batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f), + batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f, listeningHoursWithCase = 24f), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -615,5 +615,12 @@ enum class PodModel( * rate has been measured; a live fit takes over within minutes. */ val chargeFractionPerHour: Float? = null, + /** + * Apple's published TOTAL listening hours "with the charging case", in the SAME noise-mode + * condition as the headline single-charge rating. Together with the single-charge hours it + * pins the case's nominal capacity in pod recharges — the baseline for the derived case + * battery health (transfer efficiency). + */ + val listeningHoursWithCase: Float? = null, ) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index 2362c454..1050c608 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -14,6 +14,13 @@ data class AapPodState( val deviceInfo: AapDeviceInfo? = null, val settings: Map, AapSetting> = emptyMap(), val batteries: Map = emptyMap(), + /** + * Whether the LATEST battery message carried a genuine CASE reading. The case only reports + * while a pod is docked; out-of-case messages flag it DISCONNECTED (with a garbage percent) + * and the merged [batteries] map keeps the last good entry — so [batteryCase] silently goes + * stale. Consumers that must not act on frozen data (the battery estimator) gate on this. + */ + val caseIsLive: Boolean = false, val lastMessageAt: Instant? = null, val pendingAncMode: AapSetting.AncMode.Value? = null, val pendingSettingsCount: Int = 0, diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index f73c3642..8aedb430 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -241,6 +241,10 @@ internal class AapSessionEngine( } _state.value = _state.value.copy( batteries = _state.value.batteries + valid, + // Strictly per-update: a DISCONNECTED or ABSENT case entry means the merged + // map's CASE value is (about to be) frozen — never carry a stale "live" over. + caseIsLive = update.batteries[AapPodState.BatteryType.CASE] + ?.let { it.charging != AapPodState.ChargingState.DISCONNECTED } == true, lastMessageAt = timeSource.now(), ) log(TAG) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt index 07fd298c..050b631e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt @@ -147,6 +147,26 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, val hasCaseContext: Boolean get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase + /** + * The case battery ONLY when this very frame carries authoritative case data (the broadcasting + * pod itself is in the case) — never the latched last-known fallback the display accessors use. + * bit4-only frames (other pod docked, this one out) are excluded: their case bytes can be stale + * phantoms, exactly like the lid byte (see [caseLidState]). Battery-estimator input. + */ + val batteryCaseLivePercent: Float? + get() { + if (!isThisPodInThecase && !areBothPodsInCase) return null + payload.private?.asBatteryState(3)?.let { return it.level } + return when (val value = pubCaseBattery.toInt()) { + 15 -> null + else -> if (value > 10) 1.0f else value / 10f + } + } + + /** Case charging, trusted only under the same strict case context as [batteryCaseLivePercent]. */ + val isCaseChargingLive: Boolean? + get() = if (isThisPodInThecase || areBothPodsInCase) isCaseCharging else null + val caseLidState: LidState get() = LidState.fromRaw( raw = pubCaseLidState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f9db1df..ac09036d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,7 @@ Start over from the rated battery life. Reset learned data? CAPod will forget this device\'s measured drain, charging speed and battery health, and start over from the rated battery life. + Charging estimates and battery health for the case are experimental. The case only reports data while a pod is inside it. Acknowledgements Automatic bug reports @@ -479,6 +480,7 @@ Battery Health (estimated) Left Battery Health (est.) Right Battery Health (est.) + Case Battery Health (est.) ~%1$d%% Still determining — check back after a few listening sessions Device Details 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 bc86b9ca..f0b9b67a 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 @@ -23,6 +23,7 @@ class DeviceInfoDetailItemsTest : BaseTest() { batteryHealth = "Battery Health", leftBatteryHealth = "Left Battery Health", rightBatteryHealth = "Right Battery Health", + caseBatteryHealth = "Case Battery Health", ) private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" } 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 dafbca54..ffaf0078 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 @@ -39,6 +39,9 @@ class BatteryEstimatorTest : BaseTest() { estimateEnabled: Boolean = true, worn: Boolean = false, systemConnected: Boolean = false, + case: Float? = null, + caseCharging: Boolean = false, + caseLive: Boolean = true, ): PodDevice { val state = when { optimized -> ChargingState.CHARGING_OPTIMIZED @@ -48,6 +51,10 @@ class BatteryEstimatorTest : BaseTest() { 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)) + if (case != null) put( + BatteryType.CASE, + Battery(BatteryType.CASE, case, if (caseCharging) ChargingState.CHARGING else ChargingState.NOT_CHARGING), + ) } val settings = if (worn) { mapOf, AapSetting>( @@ -60,7 +67,7 @@ class BatteryEstimatorTest : BaseTest() { return PodDevice( profileId = profileId, ble = null, - aap = AapPodState(batteries = batteries, settings = settings), + aap = AapPodState(batteries = batteries, settings = settings, caseIsLive = case != null && caseLive), profileModel = model, batteryEstimateEnabled = estimateEnabled, isSystemConnected = systemConnected, @@ -554,6 +561,94 @@ class BatteryEstimatorTest : BaseTest() { result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC } + @Test + fun `a rising case charge yields a case ETA`() = runTest(UnconfinedTestDispatcher()) { + // 2%/min case rise -> 1.2/hr; at 44% that's (1 - 0.44) / 1.2 * 60 == 28 min. No spec + // seed exists for cases, so the live fit is the only source on a first charge. + val emissions = (0 until 4).map { i -> + listOf(device("p1", left = null, right = null, case = 0.20f + i * 0.08f, caseCharging = true)) + } + val result = collectEstimate(estimator(emissions)) + result["p1"].shouldNotBeNull().caseMinutesUntilCharged shouldBe 28 + } + + @Test + fun `a frozen case reading is never sampled`() = runTest(UnconfinedTestDispatcher()) { + // caseIsLive == false means the merged CASE value is a stale echo (pods undocked). + val emissions = (0 until 4).map { i -> + listOf(device("p1", left = null, right = null, case = 0.20f + i * 0.08f, caseCharging = true, caseLive = false)) + } + collectEstimate(estimator(emissions)) shouldBe emptyMap() + } + + @Test + fun `docked charging pods draw down the case into a transfer ratio`() = runTest(UnconfinedTestDispatcher()) { + // Unplugged case feeding both docked pods: pods gain 0.64 summed while the case drops + // 0.08 -> ratio 8.0. The window closes when the pods stop charging, then persists. + val docked = (0 until 5).map { i -> + listOf( + device( + "p1", + left = 0.20f + i * 0.08f, right = 0.20f + i * 0.08f, charging = true, + case = 0.50f - i * 0.02f, caseCharging = false, + ) + ) + } + val closed = listOf( + listOf(device("p1", left = 0.52f, right = 0.52f, case = 0.42f, caseCharging = false)) + ) + val emissions = docked + closed + 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 { profile -> + val transfer = profile.caseTransfer + transfer != null && transfer.ratio > 7.9f && transfer.ratio < 8.1f && + // The case never learns hourly drain rates. + profile.rates.keys.none { it.endsWith("/CASE") } + }) + } + } + + @Test + fun `a plugged-in case opens no transfer window`() = runTest(UnconfinedTestDispatcher()) { + // Case charging from cable while pods also charge — nothing here measures transfer. + val emissions = (0 until 5).map { i -> + listOf( + device( + "p1", + left = 0.20f + i * 0.08f, right = 0.20f + i * 0.08f, charging = true, + case = 0.80f, caseCharging = 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(exactly = 0) { + drainStore.save(any(), match { it.caseTransfer != null }) + } + } + @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 index 0aca9372..970388b4 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 @@ -116,6 +116,34 @@ class BatteryHealthTest : BaseTest() { BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50 } + @Test + fun `case health is the observed transfer ratio vs the nominal`() { + // Pro 2: nominal = (30 - 6) / 6 * 2 = 8.0 summed pod-fraction per case fraction. + // An observed 4.0 means the case delivers half its rated recharges -> 50%. + val profile = DrainProfile( + caseTransfer = DrainProfile.TransferRatio(ratio = 4f, updateCount = 3, updatedAt = Instant.EPOCH), + ) + val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull() + health.case shouldBe 50 + health.left shouldBe null + } + + @Test + fun `case health needs enough observed sessions`() { + val profile = DrainProfile( + caseTransfer = DrainProfile.TransferRatio(ratio = 4f, updateCount = 2, updatedAt = Instant.EPOCH), + ) + BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull() + } + + @Test + fun `case health is capped at 100`() { + val profile = DrainProfile( + caseTransfer = DrainProfile.TransferRatio(ratio = 12f, updateCount = 3, updatedAt = Instant.EPOCH), + ) + BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().case shouldBe 100 + } + @Test fun `headset slot yields a headset figure`() { // AirPods Max rated 20h; managing only 10h -> 50%. 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 e12cde2d..8415518f 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 @@ -30,6 +30,7 @@ class DrainProfileSerializationTest : BaseTest() { profile.chargeRates shouldBe emptyMap() profile.chargeBands shouldBe emptyMap() profile.listeningRates shouldBe emptyMap() + profile.caseTransfer shouldBe null profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1 } @@ -71,6 +72,11 @@ class DrainProfileSerializationTest : BaseTest() { updatedAt = Instant.ofEpochMilli(1700000000000L), ) ), + caseTransfer = DrainProfile.TransferRatio( + ratio = 6.5f, + updateCount = 4, + updatedAt = Instant.ofEpochMilli(1700000000000L), + ), ) json.decodeFromString(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt index 83eeba80..c4860ef1 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt @@ -155,6 +155,22 @@ class ModelFeaturesTest : BaseTest() { } } + @Test + fun `with-case totals exist for cased models and exceed the single-charge rating`() { + PodModel.entries.forEach { model -> + val spec = model.batterySpec ?: return@forEach + withClue(model.name) { + if (model.features.hasCase) { + val withCase = spec.listeningHoursWithCase + val single = spec.listeningHoursAncOn ?: spec.listeningHoursAncOff + (withCase != null && single != null && withCase > single) shouldBe true + } else { + spec.listeningHoursWithCase shouldBe null + } + } + } + } + @Test fun `every battery spec carries a plausible quick-charge rate`() { // Derived from Apple's published quick-charge claims; must sit inside the band the live