From a3810353f7ae01f7f01420728f1fb2d018fb205c Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 14:14:54 +0200 Subject: [PATCH] Revert "feat(battery): Add experimental case charge ETA and case battery health" This reverts commit 87a9881024cc0f024dfee8e0159f83df79962210. --- .../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, 20 insertions(+), 515 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 4ef0dfca..0179624f 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,9 +307,6 @@ 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 f77ff3f8..d8ed57e8 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,9 +24,7 @@ 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 @@ -90,13 +88,6 @@ 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 ac751e66..69a597fb 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,7 +21,6 @@ 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( @@ -38,7 +37,6 @@ internal data class DeviceInfoDetailLabels( val batteryHealth: String, val leftBatteryHealth: String, val rightBatteryHealth: String, - val caseBatteryHealth: String, ) /** @@ -50,7 +48,6 @@ internal data class BatteryHealthTexts( val left: String? = null, val right: String? = null, val headset: String? = null, - val case: String? = null, val pending: String? = null, ) @@ -68,7 +65,6 @@ 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 9b1365b5..10262103 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,11 +265,7 @@ private fun ColumnScope.DualPodsCardExpanded( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), ) - CaseRow( - device = device, - untilCharged = batteryEstimate?.caseMinutesUntilCharged - ?.let { formatBatteryDurationShort(context, it) }, - ) + CaseRow(device = device) } } } @@ -416,7 +412,6 @@ private fun PodGauge( @Composable private fun CaseRow( device: PodDevice, - untilCharged: String? = null, ) { val context = LocalContext.current @@ -459,10 +454,7 @@ private fun CaseRow( ) AapPodState.ChargingState.CHARGING -> StatusChip( icon = Icons.TwoTone.BatteryChargingFull, - // 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), + label = 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 ec5f8a04..6034d471 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,12 +9,6 @@ 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 @@ -49,5 +43,5 @@ data class BatteryEstimate( LIVE, } - val hasAny: Boolean get() = left != null || right != null || headset != null || caseMinutesUntilCharged != null + val hasAny: Boolean get() = left != null || right != null || headset != 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 1cfe9668..433dfa29 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt @@ -12,7 +12,6 @@ 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 @@ -106,16 +105,6 @@ 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 @@ -141,10 +130,6 @@ 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() @@ -152,15 +137,6 @@ 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. @@ -329,8 +305,6 @@ class BatteryEstimator @Inject constructor( } } - updateCaseTracker(profileId, tracker, device, nowMs) - persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false) return computeEstimate(profileId, tracker, device, bucket, nowMs) } @@ -347,7 +321,6 @@ 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 } } @@ -423,7 +396,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.name) + val learnedScalar = learnedChargeRate(profileId, device, slot) val specRate = device.model.batterySpec?.chargeFractionPerHour // Per band: this session's in-band fit, then the learned band rate, then the scalar @@ -431,7 +404,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.name, band) + ?: learnedChargeBand(profileId, device, slot, band) ?: liveScalar ?: learnedScalar ?: specRate?.let { it * band.specMultiplier } @@ -449,177 +422,9 @@ class BatteryEstimator @Inject constructor( private fun learnedChargeBand( profileId: ProfileId, device: PodDevice, - slotName: String, + slot: Slot, band: DrainModel.ChargeBand, - ): 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 + ): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour /** * Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next @@ -748,43 +553,6 @@ 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, @@ -794,7 +562,6 @@ class BatteryEstimator @Inject constructor( chargeRates = chargeRates, chargeBands = chargeBands, listeningRates = listeningRates, - caseTransfer = caseTransfer, ), ) } @@ -805,8 +572,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, slotName: String): Float? = - storedProfileFor(profileId, device)?.chargeRates[slotName]?.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? = @@ -896,16 +663,6 @@ 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 64bf34b8..ee745e71 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryHealth.kt @@ -26,10 +26,8 @@ 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 || case != null + val hasAny: Boolean get() = left != null || right != null || headset != null } fun estimate(profile: DrainProfile?, model: PodModel): PerPod? { @@ -41,26 +39,9 @@ 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 ec492191..9f30417f 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainProfile.kt @@ -36,13 +36,6 @@ 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( @@ -59,16 +52,6 @@ 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 ee7a96d1..c2116316 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, listeningHoursWithCase = 24f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), 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, listeningHoursWithCase = 24f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), 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, listeningHoursWithCase = 30f), + batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f), 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, listeningHoursWithCase = 30f), + batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), 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, listeningHoursWithCase = 20f), + batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), 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, listeningHoursWithCase = 24f), + batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f), 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, listeningHoursWithCase = 30f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), 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, listeningHoursWithCase = 30f), + batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f), 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, listeningHoursWithCase = 24f), + batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, rightPodIconRes = R.drawable.device_airpods_pro2_right, @@ -615,12 +615,5 @@ 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 1050c608..2362c454 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,13 +14,6 @@ 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 8aedb430..f73c3642 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,10 +241,6 @@ 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 050b631e..07fd298c 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,26 +147,6 @@ 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 ac09036d..0f9db1df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,7 +143,6 @@ 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 @@ -480,7 +479,6 @@ 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 f0b9b67a..bc86b9ca 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,7 +23,6 @@ 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 ffaf0078..dafbca54 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt @@ -39,9 +39,6 @@ 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 @@ -51,10 +48,6 @@ 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>( @@ -67,7 +60,7 @@ class BatteryEstimatorTest : BaseTest() { return PodDevice( profileId = profileId, ble = null, - aap = AapPodState(batteries = batteries, settings = settings, caseIsLive = case != null && caseLive), + aap = AapPodState(batteries = batteries, settings = settings), profileModel = model, batteryEstimateEnabled = estimateEnabled, isSystemConnected = systemConnected, @@ -561,94 +554,6 @@ 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 970388b4..0aca9372 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryHealthTest.kt @@ -116,34 +116,6 @@ 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 8415518f..e12cde2d 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainProfileSerializationTest.kt @@ -30,7 +30,6 @@ 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 } @@ -72,11 +71,6 @@ 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 c4860ef1..83eeba80 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,22 +155,6 @@ 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