Revert "feat(battery): Add experimental case charge ETA and case battery health"

This reverts commit 87a9881024.
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent d4dddb1f38
commit a3810353f7
18 changed files with 20 additions and 515 deletions
@@ -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),
@@ -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),
@@ -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))
}
@@ -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
}
@@ -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
}
@@ -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<Slot, Pair<Float, Int>> = 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<Slot, Int> = 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<Slot, Float> = mutableMapOf(),
val lastPodFractions: MutableMap<Slot, Float>,
)
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
// 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<Float, DataSource>? {
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
@@ -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 "<bucket>/<slot>" — anything else is corrupted or
@@ -36,13 +36,6 @@ data class DrainProfile(
* (which include idle wear) keep powering the time-remaining estimate.
*/
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = 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
@@ -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,
)
}
@@ -14,13 +14,6 @@ data class AapPodState(
val deviceInfo: AapDeviceInfo? = null,
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
val batteries: Map<BatteryType, Battery> = 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,
@@ -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) {
@@ -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,
-2
View File
@@ -143,7 +143,6 @@
<string name="device_battery_estimate_reset_desc">Start over from the rated battery life.</string>
<string name="device_battery_estimate_reset_confirm_title">Reset learned data?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod will forget this device\'s measured drain, charging speed and battery health, and start over from the rated battery life.</string>
<string name="device_battery_case_experimental_note">Charging estimates and battery health for the case are experimental. The case only reports data while a pod is inside it.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -480,7 +479,6 @@
<string name="device_settings_info_battery_health_label">Battery Health (estimated)</string>
<string name="device_settings_info_battery_health_left_label">Left Battery Health (est.)</string>
<string name="device_settings_info_battery_health_right_label">Right Battery Health (est.)</string>
<string name="device_settings_info_battery_health_case_label">Case Battery Health (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Still determining — check back after a few listening sessions</string>
<string name="device_settings_info_details_label">Device Details</string>
@@ -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}" }
@@ -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<kotlin.reflect.KClass<out AapSetting>, 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<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify {
drainStore.save("p1", match { 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<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(emptyMap())
coEvery { save(any(), any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
every { now() } returns now
}
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
coVerify(exactly = 0) {
drainStore.save(any(), match { it.caseTransfer != null })
}
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -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%.
@@ -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<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile
@@ -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