mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Revert "feat(battery): Add experimental case charge ETA and case battery health"
This reverts commit 87a9881024.
This commit is contained in:
@@ -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),
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
-1
@@ -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%.
|
||||
|
||||
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user