mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
ui(battery): Show charge ETA in the charging chip, make health per-pod
- The gauge line under the percentage now always shows the runtime estimate
("if used now") even while charging; the time-until-charged moved into the
charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
the info sheet, mirroring the serial rows) — single-pod listening habits or
a replaced earbud make the sides genuinely diverge, and a combined figure
would mask a failing pod
This commit is contained in:
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
|
|||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||||
@@ -295,8 +296,18 @@ fun DeviceSettingsScreen(
|
|||||||
info = info,
|
info = info,
|
||||||
labels = rememberDeviceInfoDetailLabels(),
|
labels = rememberDeviceInfoDetailLabels(),
|
||||||
formatDate = { instant -> dateFormatter.format(instant) },
|
formatDate = { instant -> dateFormatter.format(instant) },
|
||||||
batteryHealth = state.batteryHealthPercent?.let {
|
batteryHealth = state.batteryHealth?.let { health ->
|
||||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
BatteryHealthTexts(
|
||||||
|
left = health.left?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
right = health.right?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
headset = health.headset?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
DeviceInfoCard(
|
DeviceInfoCard(
|
||||||
|
|||||||
@@ -171,9 +171,9 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||||
// Health rides on the same learned data as the estimate — the per-device toggle
|
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||||
// governs both.
|
// governs both.
|
||||||
batteryHealthPercent = device
|
batteryHealth = device
|
||||||
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||||
?.let { BatteryHealth.estimatePercent(drainProfiles[profileId], it.model) },
|
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
@@ -200,8 +200,8 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
val systemBluetoothName: String? = null,
|
val systemBluetoothName: String? = null,
|
||||||
val hasCustomLongPressStemAction: Boolean = false,
|
val hasCustomLongPressStemAction: Boolean = false,
|
||||||
val batteryEstimateEnabled: Boolean = true,
|
val batteryEstimateEnabled: Boolean = true,
|
||||||
/** Derived battery health (1..100), or null when there isn't enough learned data. */
|
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||||
val batteryHealthPercent: Int? = null,
|
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||||
) {
|
) {
|
||||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -19,6 +19,8 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
|||||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||||
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal data class DeviceInfoDetailLabels(
|
internal data class DeviceInfoDetailLabels(
|
||||||
@@ -33,19 +35,43 @@ internal data class DeviceInfoDetailLabels(
|
|||||||
val leftBonded: String,
|
val leftBonded: String,
|
||||||
val rightBonded: String,
|
val rightBonded: String,
|
||||||
val batteryHealth: String,
|
val batteryHealth: String,
|
||||||
|
val leftBatteryHealth: String,
|
||||||
|
val rightBatteryHealth: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted. */
|
||||||
|
internal data class BatteryHealthTexts(
|
||||||
|
val left: String? = null,
|
||||||
|
val right: String? = null,
|
||||||
|
val headset: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
|
||||||
|
if (health == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
when {
|
||||||
|
health.left != null && health.right != null -> add(
|
||||||
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
|
||||||
|
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
|
||||||
|
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
|
||||||
|
}
|
||||||
|
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun buildDeviceInfoDetailItems(
|
internal fun buildDeviceInfoDetailItems(
|
||||||
info: AapDeviceInfo?,
|
info: AapDeviceInfo?,
|
||||||
labels: DeviceInfoDetailLabels,
|
labels: DeviceInfoDetailLabels,
|
||||||
batteryHealth: String? = null,
|
batteryHealth: BatteryHealthTexts? = null,
|
||||||
formatDate: (Instant) -> String,
|
formatDate: (Instant) -> String,
|
||||||
): List<DeviceDetailItem> {
|
): List<DeviceDetailItem> {
|
||||||
// Battery health is derived locally, so it's available (and shows the info button) even for
|
// Battery health is derived locally, so it's available (and shows the info button) even for
|
||||||
// BLE-only devices that never produce an AAP device-info response.
|
// BLE-only devices that never produce an AAP device-info response.
|
||||||
if (info == null) {
|
if (info == null) return healthItems(batteryHealth, labels)
|
||||||
return batteryHealth?.let { listOf(DeviceDetailItem.Single(labels.batteryHealth, it)) } ?: emptyList()
|
|
||||||
}
|
|
||||||
return buildList {
|
return buildList {
|
||||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||||
@@ -89,6 +115,6 @@ internal fun buildDeviceInfoDetailItems(
|
|||||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||||
}
|
}
|
||||||
batteryHealth?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
addAll(healthItems(batteryHealth, labels))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package eu.darken.capod.main.ui.overview.cards
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -240,9 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
timeRemaining = batteryEstimate?.left?.let {
|
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
formatEstimateText(context, it, isCharging = device.isLeftPodCharging == true)
|
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
@@ -255,9 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
timeRemaining = batteryEstimate?.right?.let {
|
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
formatEstimateText(context, it, isCharging = device.isRightPodCharging == true)
|
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +310,7 @@ private fun PodGauge(
|
|||||||
showMicrophone: Boolean,
|
showMicrophone: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
timeRemaining: String? = null,
|
timeRemaining: String? = null,
|
||||||
|
untilCharged: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||||
@@ -405,23 +403,11 @@ private fun PodGauge(
|
|||||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||||
|
chargingDetail = untilCharged,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The gauge's small estimate line: while charging, the time until full (language-neutral "⚡ 25m")
|
|
||||||
* or NOTHING — a bare runtime number next to a charging chip ("1% · 4m") inevitably reads as a
|
|
||||||
* four-minute charge. The runtime estimate only shows while not charging. Shared with
|
|
||||||
* [SinglePodsCard] (same package).
|
|
||||||
*/
|
|
||||||
internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod, isCharging: Boolean): String? = when {
|
|
||||||
pod.minutesUntilCharged != null ->
|
|
||||||
context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, pod.minutesUntilCharged))
|
|
||||||
isCharging -> null
|
|
||||||
else -> formatBatteryDurationShort(context, pod.minutesRemaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaseRow(
|
private fun CaseRow(
|
||||||
|
|||||||
@@ -267,12 +267,10 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val headsetEstimate = batteryEstimate?.headset?.let {
|
val headsetEstimate = batteryEstimate?.headset
|
||||||
formatEstimateText(context, it, isCharging = device.isHeadsetBeingCharged == true)
|
|
||||||
}
|
|
||||||
if (headsetEstimate != null) {
|
if (headsetEstimate != null) {
|
||||||
Text(
|
Text(
|
||||||
text = headsetEstimate,
|
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -296,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_optimized_label),
|
label = stringResource(R.string.pods_charging_optimized_label),
|
||||||
)
|
)
|
||||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
AapPodState.ChargingState.CHARGING -> {
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
// Time-until-charged lives in the charging chip; the in-ring estimate
|
||||||
label = stringResource(R.string.pods_charging_label),
|
// always means runtime, so the two can't be confused.
|
||||||
)
|
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
|
||||||
|
?.let { formatBatteryDurationShort(context, it) }
|
||||||
|
StatusChip(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
label = untilCharged
|
||||||
|
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
|
||||||
|
?: stringResource(R.string.pods_charging_label),
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
if (device.isBeingWorn == true) {
|
if (device.isBeingWorn == true) {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fun StatusChipRow(
|
|||||||
inEarLabel: String,
|
inEarLabel: String,
|
||||||
microphoneLabel: String,
|
microphoneLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
chargingDetail: String? = null,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier.animateContentSize(),
|
modifier = modifier.animateContentSize(),
|
||||||
@@ -82,9 +83,11 @@ fun StatusChipRow(
|
|||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = chargingOptimizedLabel,
|
label = chargingOptimizedLabel,
|
||||||
)
|
)
|
||||||
|
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
|
||||||
|
// estimate line under the percentage always means runtime, so the two can't be confused.
|
||||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = chargingLabel,
|
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
|
||||||
)
|
)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
|
|||||||
chargingOptimizedLabel = "Optimized",
|
chargingOptimizedLabel = "Optimized",
|
||||||
inEarLabel = "In Ear",
|
inEarLabel = "In Ear",
|
||||||
microphoneLabel = "Mic",
|
microphoneLabel = "Mic",
|
||||||
|
chargingDetail = "25m",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ data class BatteryEstimate(
|
|||||||
* bounded by the model's rated life
|
* bounded by the model's rated life
|
||||||
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
|
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
|
||||||
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
|
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
|
||||||
* or the final trickle phase). When set, the UI shows this instead of [minutesRemaining].
|
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
|
||||||
|
* the gauge line as the runtime projection.
|
||||||
*/
|
*/
|
||||||
data class Pod(
|
data class Pod(
|
||||||
val minutesRemaining: Int,
|
val minutesRemaining: Int,
|
||||||
|
|||||||
@@ -5,33 +5,49 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a rough battery-health percentage from learned drain rates vs the model's rated battery
|
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
|
||||||
* life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based proxy:
|
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
|
||||||
* a battery that only lasts 4.5h of a rated 6h reads as ~75%.
|
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||||
*
|
*
|
||||||
* The MEDIAN of the qualifying learned rates is used rather than the best or worst: sessions where
|
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||||
* the pods idled (in-ear, nothing playing) drain slower than the listening rating and would pull a
|
* genuinely diverge, and a combined figure would mask a failing pod. Within a pod, the MEDIAN of its
|
||||||
* "best" pick to a meaningless 100%, while call-heavy or cold sessions drain faster and would drag
|
* qualifying learned rates is used rather than the best or worst: sessions where the pod idled
|
||||||
* a "worst" pick into false doom. The median lands between both confounds. It remains an estimate —
|
* (in-ear, nothing playing) drain slower than the listening rating and would pull a "best" pick to a
|
||||||
* label it as such in the UI.
|
* meaningless 100%, while call-heavy or cold sessions drain faster and would drag a "worst" pick
|
||||||
|
* into false doom. The median lands between both confounds. It remains an estimate — label it as
|
||||||
|
* such in the UI.
|
||||||
*/
|
*/
|
||||||
object BatteryHealth {
|
object BatteryHealth {
|
||||||
|
|
||||||
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
||||||
const val MIN_UPDATE_COUNT = 3
|
const val MIN_UPDATE_COUNT = 3
|
||||||
|
|
||||||
private val VALID_SLOTS = setOf("LEFT", "RIGHT", "HEADSET")
|
data class PerPod(
|
||||||
|
val left: Int? = null,
|
||||||
|
val right: Int? = null,
|
||||||
|
val headset: Int? = null,
|
||||||
|
) {
|
||||||
|
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||||
|
}
|
||||||
|
|
||||||
fun estimatePercent(profile: DrainProfile?, model: PodModel): Int? {
|
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
|
||||||
if (profile == null) return null
|
if (profile == null) return null
|
||||||
val spec = model.batterySpec ?: return null
|
val spec = model.batterySpec ?: return null
|
||||||
if (!profile.matchesModel(model)) return null
|
if (!profile.matchesModel(model)) return null
|
||||||
|
|
||||||
|
return PerPod(
|
||||||
|
left = slotPercent(profile, spec, "LEFT"),
|
||||||
|
right = slotPercent(profile, spec, "RIGHT"),
|
||||||
|
headset = slotPercent(profile, spec, "HEADSET"),
|
||||||
|
).takeIf { it.hasAny }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||||
val ratios = profile.rates.mapNotNull { (key, rate) ->
|
val ratios = profile.rates.mapNotNull { (key, rate) ->
|
||||||
// Keys must be exactly "<bucket>/<slot>" with a known slot — anything else is corrupted
|
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||||
// or future-format data and must not feed a health figure.
|
// future-format data and must not feed a health figure.
|
||||||
val parts = key.split('/')
|
val parts = key.split('/')
|
||||||
if (parts.size != 2 || parts[1] !in VALID_SLOTS) return@mapNotNull null
|
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
|
||||||
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
|
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
|
||||||
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
|
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
|
||||||
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
|
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
|
||||||
|
|||||||
@@ -319,7 +319,6 @@
|
|||||||
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
<string name="battery_time_remaining_format_h">%1$dh</string>
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
<string name="battery_time_remaining_format_m">%1$dm</string>
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="battery_time_until_charged_short">⚡ %1$s</string>
|
|
||||||
<string name="permission_post_notifications_label">Show notifications</string>
|
<string name="permission_post_notifications_label">Show notifications</string>
|
||||||
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
|
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
|
||||||
|
|
||||||
@@ -477,7 +476,9 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
|
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
|
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
|
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
|
||||||
<string name="device_settings_info_battery_health_label">Battery health (estimated)</string>
|
<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_value">~%1$d%%</string>
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
<string name="device_settings_info_details_label">Device Details</string>
|
<string name="device_settings_info_details_label">Device Details</string>
|
||||||
<string name="device_settings_info_details_action">Show device details</string>
|
<string name="device_settings_info_details_action">Show device details</string>
|
||||||
|
|||||||
+3
-2
@@ -14,6 +14,7 @@ import eu.darken.capod.monitor.core.MonitorModeResolver
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryHealth
|
||||||
import eu.darken.capod.monitor.core.battery.DrainProfile
|
import eu.darken.capod.monitor.core.battery.DrainProfile
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
@@ -571,7 +572,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.initialize(testAddress)
|
vm.initialize(testAddress)
|
||||||
|
|
||||||
vm.state.first().batteryHealthPercent shouldBe 50
|
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -606,7 +607,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.initialize(testAddress)
|
vm.initialize(testAddress)
|
||||||
|
|
||||||
vm.state.first().batteryHealthPercent shouldBe null
|
vm.state.first().batteryHealth shouldBe null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+43
-4
@@ -21,6 +21,8 @@ class DeviceInfoDetailItemsTest : BaseTest() {
|
|||||||
leftBonded = "Left Bonded",
|
leftBonded = "Left Bonded",
|
||||||
rightBonded = "Right Bonded",
|
rightBonded = "Right Bonded",
|
||||||
batteryHealth = "Battery Health",
|
batteryHealth = "Battery Health",
|
||||||
|
leftBatteryHealth = "Left Battery Health",
|
||||||
|
rightBatteryHealth = "Right Battery Health",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
|
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
|
||||||
@@ -59,9 +61,17 @@ class DeviceInfoDetailItemsTest : BaseTest() {
|
|||||||
@Test
|
@Test
|
||||||
fun `battery health shows without AapDeviceInfo`() {
|
fun `battery health shows without AapDeviceInfo`() {
|
||||||
// BLE-only devices never produce an AAP info response but can still have learned health.
|
// BLE-only devices never produce an AAP info response but can still have learned health.
|
||||||
val result = buildDeviceInfoDetailItems(null, labels, batteryHealth = "~85%", formatDate = formatter)
|
val result = buildDeviceInfoDetailItems(
|
||||||
|
null,
|
||||||
|
labels,
|
||||||
|
batteryHealth = BatteryHealthTexts(left = "~85%", right = "~78%"),
|
||||||
|
formatDate = formatter,
|
||||||
|
)
|
||||||
result shouldContainExactly listOf(
|
result shouldContainExactly listOf(
|
||||||
DeviceDetailItem.Single("Battery Health", "~85%"),
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single("Left Battery Health", "~85%"),
|
||||||
|
end = DeviceDetailItem.Single("Right Battery Health", "~78%"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,14 +80,43 @@ class DeviceInfoDetailItemsTest : BaseTest() {
|
|||||||
val result = buildDeviceInfoDetailItems(
|
val result = buildDeviceInfoDetailItems(
|
||||||
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
|
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
|
||||||
labels,
|
labels,
|
||||||
batteryHealth = "~72%",
|
batteryHealth = BatteryHealthTexts(left = "~72%", right = "~90%"),
|
||||||
formatDate = formatter,
|
formatDate = formatter,
|
||||||
)
|
)
|
||||||
result shouldContainExactly listOf(
|
result shouldContainExactly listOf(
|
||||||
DeviceDetailItem.Single("Manufacturer", "Apple"),
|
DeviceDetailItem.Single("Manufacturer", "Apple"),
|
||||||
DeviceDetailItem.Single("Serial Number", "ABC123"),
|
DeviceDetailItem.Single("Serial Number", "ABC123"),
|
||||||
DeviceDetailItem.Single("Firmware", "7A305"),
|
DeviceDetailItem.Single("Firmware", "7A305"),
|
||||||
DeviceDetailItem.Single("Battery Health", "~72%"),
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single("Left Battery Health", "~72%"),
|
||||||
|
end = DeviceDetailItem.Single("Right Battery Health", "~90%"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single-sided battery health yields a Single row`() {
|
||||||
|
val result = buildDeviceInfoDetailItems(
|
||||||
|
null,
|
||||||
|
labels,
|
||||||
|
batteryHealth = BatteryHealthTexts(left = "~85%"),
|
||||||
|
formatDate = formatter,
|
||||||
|
)
|
||||||
|
result shouldContainExactly listOf(
|
||||||
|
DeviceDetailItem.Single("Left Battery Health", "~85%"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `headset battery health yields a Single row with the generic label`() {
|
||||||
|
val result = buildDeviceInfoDetailItems(
|
||||||
|
null,
|
||||||
|
labels,
|
||||||
|
batteryHealth = BatteryHealthTexts(headset = "~64%"),
|
||||||
|
formatDate = formatter,
|
||||||
|
)
|
||||||
|
result shouldContainExactly listOf(
|
||||||
|
DeviceDetailItem.Single("Battery Health", "~64%"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core.battery
|
|||||||
|
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import testhelpers.BaseTest
|
import testhelpers.BaseTest
|
||||||
@@ -21,28 +22,45 @@ class BatteryHealthTest : BaseTest() {
|
|||||||
fun `health is the ratio of rated to learned drain`() {
|
fun `health is the ratio of rated to learned drain`() {
|
||||||
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
|
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
|
||||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `health is computed per pod`() {
|
||||||
|
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
|
||||||
|
// diverge — each pod gets its own figure instead of one masking the other.
|
||||||
|
val profile = DrainProfile(
|
||||||
|
rates = mapOf(
|
||||||
|
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
|
||||||
|
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
|
||||||
|
health.left shouldBe 50
|
||||||
|
health.right shouldBe 100
|
||||||
|
health.headset shouldBe null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `health is capped at 100`() {
|
fun `health is capped at 100`() {
|
||||||
// Idle-heavy usage drains slower than the listening rating — never report over-health.
|
// Idle-heavy usage drains slower than the listening rating — never report over-health.
|
||||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
|
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 100
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `health uses the median across learned rates`() {
|
fun `health uses the median across a pod's learned rates`() {
|
||||||
// Three qualifying entries at 100% / 50% / 25% equivalent -> the median (50%) wins, so a
|
// Three qualifying LEFT entries at 100% / 50% / 25% equivalent -> the median (50%) wins,
|
||||||
// single gentle idle session can't inflate the figure and one hard session can't tank it.
|
// so a single gentle idle session can't inflate the figure and one hard session can't
|
||||||
|
// tank it.
|
||||||
val profile = DrainProfile(
|
val profile = DrainProfile(
|
||||||
rates = mapOf(
|
rates = mapOf(
|
||||||
"UNKNOWN/LEFT" to rate(1f / 6f),
|
"UNKNOWN/LEFT" to rate(1f / 6f),
|
||||||
"UNKNOWN/RIGHT" to rate(1f / 3f),
|
"ON/LEFT" to rate(1f / 3f),
|
||||||
"OFF/LEFT" to rate(1f / 1.5f),
|
"OFF/LEFT" to rate(1f / 1.5f),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2) shouldBe 50
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -50,19 +68,19 @@ class BatteryHealthTest : BaseTest() {
|
|||||||
val profile = DrainProfile(
|
val profile = DrainProfile(
|
||||||
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
|
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
|
||||||
)
|
)
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `models without a rating have no health`() {
|
fun `models without a rating have no health`() {
|
||||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.UNKNOWN).shouldBeNull()
|
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `no profile or no qualifying rates yields null`() {
|
fun `no profile or no qualifying rates yields null`() {
|
||||||
BatteryHealth.estimatePercent(null, PodModel.AIRPODS_PRO2).shouldBeNull()
|
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||||
BatteryHealth.estimatePercent(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
|
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -71,7 +89,7 @@ class BatteryHealthTest : BaseTest() {
|
|||||||
model = PodModel.AIRPODS_PRO.name,
|
model = PodModel.AIRPODS_PRO.name,
|
||||||
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
|
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
|
||||||
)
|
)
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,7 +105,7 @@ class BatteryHealthTest : BaseTest() {
|
|||||||
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
|
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -95,6 +113,15 @@ class BatteryHealthTest : BaseTest() {
|
|||||||
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
|
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
|
||||||
// ON rating — not 40% of the OFF one.
|
// ON rating — not 40% of the OFF one.
|
||||||
val profile = DrainProfile(rates = mapOf("ON/LEFT" to rate(0.5f)))
|
val profile = DrainProfile(rates = mapOf("ON/LEFT" to rate(0.5f)))
|
||||||
BatteryHealth.estimatePercent(profile, PodModel.AIRPODS_GEN4_ANC) shouldBe 50
|
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `headset slot yields a headset figure`() {
|
||||||
|
// AirPods Max rated 20h; managing only 10h -> 50%.
|
||||||
|
val profile = DrainProfile(rates = mapOf("ON/HEADSET" to rate(0.1f)))
|
||||||
|
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
|
||||||
|
health.headset shouldBe 50
|
||||||
|
health.left shouldBe null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user