feat(battery): Make estimate per-device and seed from model specs

- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
  the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
  Battery card; the charge notification now works for any live device, not only classic
  audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
  drain stops over-promising within a couple of updates
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 9606a33f15
commit aae8ad62bd
26 changed files with 718 additions and 176 deletions
@@ -85,8 +85,6 @@ class GeneralSettings @Inject constructor(
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val batteryEstimateEnabled = dataStore.createValue("ui.overview.battery_estimate.enabled", true)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -181,6 +181,8 @@ fun DeviceSettingsScreenHost(
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
)
}
@@ -224,6 +226,8 @@ fun DeviceSettingsScreen(
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
onResetBatteryEstimate: () -> Unit = {},
) {
val device = state.device
val features = device?.model?.features
@@ -363,10 +367,26 @@ fun DeviceSettingsScreen(
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onOpenIssueTracker = onOpenIssueTracker,
)
}
}
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
if (device != null && features != null && device.isLive) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
isPro = isPro,
chargeCapControlEnabled = enabled,
estimateEnabled = state.batteryEstimateEnabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
onChargedThresholdChange = onChargedThresholdChange,
onChargedSlotScopeChange = onChargedSlotScopeChange,
onOpenIssueTracker = onOpenIssueTracker,
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
onResetEstimate = onResetBatteryEstimate,
)
}
}
@@ -439,18 +459,6 @@ fun DeviceSettingsScreen(
}
}
// ── Battery ──────────────────────────────────
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
enabled = enabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
)
}
}
// ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
@@ -22,6 +22,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -55,6 +56,7 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val batteryEstimator: BatteryEstimator,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
@@ -134,9 +136,9 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val nudgeAvailability = args[7] as NudgeAvailability
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
?.stemActions
val stemActions = appleProfile?.stemActions
val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr ->
try {
@@ -158,6 +160,7 @@ class DeviceSettingsViewModel @Inject constructor(
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true,
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
)
}
}.asLiveState()
@@ -183,6 +186,7 @@ class DeviceSettingsViewModel @Inject constructor(
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -473,6 +477,19 @@ class DeviceSettingsViewModel @Inject constructor(
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
}
// ── Battery estimate (per-profile) ───────────────────────────────────────
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
}
fun resetBatteryEstimate() = launch {
val profileId = targetProfileId.value ?: return@launch
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
batteryEstimator.reset(profileId)
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
@@ -2,41 +2,161 @@ package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.NotificationsActive
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
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.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
/**
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
* used by every other boolean setting.
* The device's "Battery" settings, grouping everything charge/battery-related:
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
* works for any live device (BLE or AAP), not just when the phone is the audio source.
* - The dashboard time-remaining estimate: a per-device toggle (disabling pauses/hides without
* discarding learned data) and a reset that wipes the learned drain so it starts over from the
* model's rated battery life.
*
* Each row is gated independently; the whole card hides when nothing applies.
*/
@Composable
internal fun BatteryCard(
device: PodDevice,
features: PodModel.Features,
enabled: Boolean,
isPro: Boolean,
chargeCapControlEnabled: Boolean,
estimateEnabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onEstimateEnabledChange: (Boolean) -> Unit = {},
onResetEstimate: () -> Unit = {},
) {
if (!features.hasDynamicEndOfCharge) return
val cap = device.dynamicEndOfCharge ?: return
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
val showLiveControls = device.isLive && device.profileId != null
if (chargeCap == null && !showLiveControls) return
val reactions = device.reactions
var showResetConfirm by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = cap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = enabled,
if (chargeCap != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = chargeCap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = chargeCapControlEnabled,
)
}
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.NotificationsActive,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
SettingsSwitchItem(
icon = Icons.TwoTone.Schedule,
title = stringResource(R.string.device_battery_estimate_toggle_label),
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
checked = estimateEnabled,
onCheckedChange = onEstimateEnabledChange,
)
SettingsBaseItem(
icon = Icons.TwoTone.RestartAlt,
title = stringResource(R.string.device_battery_estimate_reset_action),
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
onClick = { showResetConfirm = true },
)
}
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showResetConfirm) {
AlertDialog(
onDismissRequest = { showResetConfirm = false },
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
showResetConfirm = false
onResetEstimate()
},
) {
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
}
},
dismissButton = {
TextButton(onClick = { showResetConfirm = false }) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
}
@@ -48,8 +168,10 @@ private fun BatteryCardEnabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = true,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = true,
chargeCapControlEnabled = true,
estimateEnabled = true,
)
}
@@ -60,7 +182,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = false,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = false,
chargeCapControlEnabled = false,
estimateEnabled = false,
)
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
@@ -39,7 +38,6 @@ import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
@@ -47,7 +45,6 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
@@ -67,9 +64,6 @@ internal fun ReactionsCard(
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
@@ -77,7 +71,6 @@ internal fun ReactionsCard(
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -258,40 +251,6 @@ internal fun ReactionsCard(
text = stringResource(R.string.settings_popup_info_not_in_app),
)
}
ReactionsDivider()
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
}
if (showConversationActionDialog) {
@@ -305,17 +264,6 @@ internal fun ReactionsCard(
)
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -465,11 +465,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
userExpandedIds = setOf("preview-single"),
batteryEstimates = mapOf(
"preview-dual-full" to BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, isLearned = false),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, isLearned = false),
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
),
),
@@ -88,7 +88,6 @@ class OverviewViewModel @Inject constructor(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val batteryEstimateEnabled: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
@@ -125,14 +124,12 @@ class OverviewViewModel @Inject constructor(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
generalSettings.batteryEstimateEnabled.flow,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimateEnabled, batteryEstimates ->
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimateEnabled = batteryEstimateEnabled,
batteryEstimates = batteryEstimates,
)
}
@@ -223,7 +220,6 @@ class OverviewViewModel @Inject constructor(
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimateEnabled = uiSettings.batteryEstimateEnabled,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -243,17 +239,16 @@ class OverviewViewModel @Inject constructor(
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
/**
* Time-remaining estimate to show for [device], or null when the user disabled the feature,
* the device isn't live (no estimate for cached/offline cards), or no rate has been learned.
* Time-remaining estimate to show for [device], or null when the device has the estimate
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
*/
fun estimateFor(device: PodDevice): BatteryEstimate? {
if (!batteryEstimateEnabled) return null
if (!device.batteryEstimateEnabled) return null
if (!device.isLive) return null
val profileId = device.profileId ?: return null
return batteryEstimates[profileId]
@@ -479,8 +479,8 @@ private fun DualPodsCardFullPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, isLearned = false),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, isLearned = false),
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
onDeviceSettings = {},
)
@@ -489,16 +489,33 @@ private fun DualPodsCardFullPreview() = PreviewWrapper {
@Preview2
@Composable
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
// isLearned = true: rate seeded from persisted history on reconnect, before this session has
// gathered enough live samples.
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
// enough live samples.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, isLearned = true),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, isLearned = true),
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
),
onDeviceSettings = {},
)
@@ -359,7 +359,7 @@ private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
showDebug = false,
now = SystemTimeSource.now(),
batteryEstimate = BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon
@@ -61,7 +60,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onBatteryEstimateEnabledChanged = { enabled -> vm.setBatteryEstimateEnabled(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -80,7 +78,6 @@ fun GeneralSettingsScreen(
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onBatteryEstimateEnabledChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
@@ -218,21 +215,6 @@ fun GeneralSettingsScreen(
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_overview_battery_estimate_label),
subtitle = stringResource(R.string.settings_overview_battery_estimate_description),
icon = Icons.TwoTone.Schedule,
onClick = { onBatteryEstimateEnabledChanged(!state.batteryEstimateEnabled) },
trailingContent = {
Switch(
checked = state.batteryEstimateEnabled,
onCheckedChange = onBatteryEstimateEnabledChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
@@ -304,7 +286,6 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
batteryEstimateEnabled = true,
themeState = ThemeState(),
)
@@ -320,7 +301,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
onBatteryEstimateEnabledChanged = {},
)
}
@@ -336,6 +316,5 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
onBatteryEstimateEnabledChanged = {},
)
}
@@ -35,7 +35,6 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val batteryEstimateEnabled: Boolean,
val themeState: ThemeState,
)
@@ -45,10 +44,9 @@ class GeneralSettingsViewModel @Inject constructor(
combine(
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
generalSettings.batteryEstimateEnabled.flow,
) { showNotif, keepNotif, batteryEstimate ->
) { showNotif, keepNotif ->
@Suppress("USELESS_CAST")
arrayOf<Any>(showNotif as Any, keepNotif as Any, batteryEstimate as Any)
arrayOf<Any>(showNotif as Any, keepNotif as Any)
},
combine(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -70,7 +68,6 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
batteryEstimateEnabled = general[2] as Boolean,
themeState = themeState,
)
}.asLiveState()
@@ -105,11 +102,6 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setBatteryEstimateEnabled(enabled: Boolean) {
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
generalSettings.batteryEstimateEnabled.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) {
@@ -79,6 +79,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile?.address in connectedAddresses,
)
}
@@ -115,6 +116,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in state.connectedAddresses,
)
}
@@ -181,6 +183,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in connectedAddresses,
)
}
@@ -288,6 +291,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
)
}
@@ -60,6 +60,8 @@ data class PodDevice(
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(),
/** Whether the dashboard battery time-remaining estimate is enabled for this device (per-profile). */
val batteryEstimateEnabled: Boolean = true,
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
val isSystemConnected: Boolean = false,
) {
@@ -94,7 +94,12 @@ class BatteryDrainStore @Inject constructor(
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
id.toFile().delete()
val file = id.toFile()
if (file.exists() && !file.delete()) {
// In-memory is cleared regardless; warn so a failed delete (which would resurrect the
// rate on the next loadAll()) is at least visible rather than silently undoing a reset.
log(TAG, Logging.Priority.ERROR) { "delete($id): failed to remove $file" }
}
_profiles.value -= id
}
}
@@ -13,13 +13,30 @@ data class BatteryEstimate(
/**
* @property minutesRemaining smoothed estimate of minutes until this pod empties
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
* @property isLearned true when the rate came from persisted history rather than the live session
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
* bounded by the model's rated life
*/
data class Pod(
val minutesRemaining: Int,
val fractionPerHour: Float,
val isLearned: Boolean,
)
val source: Source,
) {
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
val isProvisional: Boolean get() = source == Source.SPEC
}
/** Where a pod's drain rate was derived from, in order of increasing confidence. */
enum class Source {
/** Apple's published rating — shown immediately on connect, before any drain is observed. */
SPEC,
/** Persisted rate from earlier sessions with this device. */
LEARNED,
/** Measured from the current session's observed drain. */
LIVE,
}
val hasAny: Boolean get() = left != null || right != null || headset != null
}
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
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.HasChargeDetection
@@ -17,8 +18,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -90,6 +95,26 @@ class BatteryEstimator @Inject constructor(
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
// Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate.
private val mutex = Mutex()
/**
* Wipes ALL learned state for [profileId] — the in-memory tracker, the current estimate, and the
* persisted rates — under the same lock as [process], so it's atomic w.r.t. sampling/persisting.
* The next live emission re-seeds the device from its model rating. Safe to call when the monitor
* isn't running (trackers/estimates are already empty; only the store delete has effect).
*/
suspend fun reset(profileId: ProfileId) = withContext(NonCancellable) {
// NonCancellable so a cancelled caller (e.g. the settings screen closing) can't leave the
// wipe half-applied — memory cleared but persisted rates surviving to resurrect later.
mutex.withLock {
log(TAG) { "reset($profileId)" }
trackers.remove(profileId)
_estimates.value = _estimates.value - profileId
drainStore.delete(profileId)
}
}
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices -> process(devices) }
.onCompletion {
@@ -101,18 +126,19 @@ class BatteryEstimator @Inject constructor(
.map { }
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
private suspend fun process(devices: List<PodDevice>) {
// Only profiles with a single, unambiguous live candidate. DeviceMonitor keeps multiple
// same-profile devices when there's no IRK-verified match, and blending two physical
// devices' levels would be garbage — skip those.
private suspend fun process(devices: List<PodDevice>) = mutex.withLock {
// Only profiles with a single, unambiguous live candidate that has the estimate enabled.
// DeviceMonitor keeps multiple same-profile devices when there's no IRK-verified match, and
// blending two physical devices' levels would be garbage — skip those. A device with the
// feature disabled is skipped entirely (not sampled, not persisted).
val unambiguous = devices
.filter { it.profileId != null && it.isLive }
.filter { it.profileId != null && it.isLive && it.batteryEstimateEnabled }
.groupBy { it.profileId!! }
.filter { (_, group) -> group.size == 1 }
.mapValues { (_, group) -> group.single() }
val next = _estimates.value.toMutableMap()
// Drop estimates for profiles no longer live/unambiguous this emission (offline gating).
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
next.keys.retainAll(unambiguous.keys)
for ((profileId, device) in unambiguous) {
@@ -137,7 +163,7 @@ class BatteryEstimator @Inject constructor(
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
// then start fresh so OFF-rate samples never blend into ON-rate.
if (tracker.modeBucket != bucket) {
persistFromWindow(profileId, tracker, tracker.modeBucket, nowMs, force = true)
persistFromWindow(profileId, tracker, device, tracker.modeBucket, nowMs, force = true)
tracker.resetWindow()
tracker.modeBucket = bucket
}
@@ -169,7 +195,7 @@ class BatteryEstimator @Inject constructor(
}
}
persistFromWindow(profileId, tracker, bucket, nowMs, force = false)
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
return computeEstimate(profileId, tracker, device, bucket)
}
@@ -195,19 +221,48 @@ class BatteryEstimator @Inject constructor(
bucket: String,
slot: Slot,
): BatteryEstimate.Pod? {
if (device.liveCharging(slot) == true) return null
val fraction = device.liveFraction(slot) ?: return null
val liveRate = DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
val rate = liveRate ?: learnedRate(profileId, bucket, slot) ?: return null
val minutes = DrainModel.minutesRemaining(fraction, rate) ?: return null
val spec = device.specRate(bucket)
// While charging the battery is rising, so there's no live drain to fit — project the runtime
// "if used now" from the learned rate or the model rating instead. Live sampling is skipped
// here (and updateTracker already clears the window while charging), so a rising level is
// never learned as a drain — we only surface a projection so the estimate stays visible in
// the case, climbing as the pod charges.
val charging = device.liveCharging(slot) == true
val live = if (charging) {
null
} else {
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
?.takeIf { plausibleForModel(it, spec) }
}
val learned = learnedRate(profileId, bucket, slot)
val displayRate = live ?: learned ?: spec ?: return null
val smoothed = DrainModel.blendMinutes(tracker.lastMinutes[slot], minutes)
tracker.lastMinutes[slot] = smoothed
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
// source: a degraded battery only ever drains FASTER than new-condition spec, so a measured
// rate normally wins — spec only bites when a source implausibly implies MORE life than Apple.
val effectiveRate = spec?.let { maxOf(displayRate, it) } ?: displayRate
val minutes = DrainModel.minutesRemaining(fraction, effectiveRate) ?: return null
// Smooth against the last displayed value — but while charging neither read nor write that
// state: the in-case projection is shown raw and must not pollute the discharge history, else
// the first estimate after undocking would blend against a stale charging projection.
var smoothed = DrainModel.blendMinutes(if (charging) null else tracker.lastMinutes[slot], minutes)
// Smoothing lags, so just after a drop it can sit above the ceiling — clamp the shown value to
// the spec cap too, so we never DISPLAY more life than Apple rates even mid-transition.
spec?.let { DrainModel.minutesRemaining(fraction, it) }?.let { smoothed = minOf(smoothed, it) }
if (!charging) tracker.lastMinutes[slot] = smoothed
val source = when {
live != null -> BatteryEstimate.Source.LIVE
learned != null -> BatteryEstimate.Source.LEARNED
else -> BatteryEstimate.Source.SPEC
}
return BatteryEstimate.Pod(
minutesRemaining = smoothed,
fractionPerHour = rate,
isLearned = liveRate == null,
fractionPerHour = effectiveRate,
source = source,
)
}
@@ -218,17 +273,21 @@ class BatteryEstimator @Inject constructor(
private suspend fun persistFromWindow(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
force: Boolean,
) {
val existing = drainStore.profiles.value[profileId] ?: DrainProfile()
val spec = device.specRate(bucket)
var rates = existing.rates
var changed = false
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val liveRate = DrainModel.slopeFractionPerHour(history.toList()) ?: continue
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
val liveRate = DrainModel.slopeFractionPerHour(history.toList())
?.takeIf { plausibleForModel(it, spec) } ?: continue
val key = rateKey(bucket, slot)
val lastPersist = tracker.lastPersistAtMs[key]
@@ -262,6 +321,33 @@ class BatteryEstimator @Inject constructor(
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
/**
* Apple's rated drain (fraction/hour) for this model in the given ANC bucket, or null when the
* model has no published rating (Beats / unknown). Used to seed an estimate before any drain is
* observed and as the upper bound on remaining life. When the mode isn't known yet (BLE-only, or
* a just-connected AAP session) the shorter of the two ratings is used, so an unknown mode can't
* over-promise.
*/
private fun PodDevice.specRate(bucket: String): Float? {
val spec = model.batterySpec ?: return null
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
val hours = when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
MODE_UNKNOWN -> listOfNotNull(on, off).minOrNull()
else -> on ?: off // ON / TRANSPARENCY / ADAPTIVE
} ?: return null
return if (hours.isFinite() && hours > 0f) 1f / hours else null
}
/**
* A measured rate is trusted only when it isn't absurdly faster than the model's rated drain. The
* slow side is intentionally NOT bounded: a genuinely gentle drain is real data worth learning,
* and the spec ceiling already stops a slow rate from over-reporting on screen.
*/
private fun plausibleForModel(rate: Float, spec: Float?): Boolean =
spec == null || rate <= spec * SPEC_BAND_MAX
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
// cache); learning from a re-stamped stale reading would poison the rate.
private fun PodDevice.liveFraction(slot: Slot): Float? {
@@ -270,7 +356,8 @@ class BatteryEstimator @Inject constructor(
Slot.RIGHT -> aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
}
return value?.takeIf { isKnownBattery(it) }
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
return value?.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)
}
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
@@ -286,6 +373,9 @@ class BatteryEstimator @Inject constructor(
private const val MODE_UNKNOWN = "UNKNOWN"
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
private const val SPEC_BAND_MAX = 4f
/** A gap longer than this between updates means the device was away — reset its window. */
private const val STALE_GAP_MS = 15 * 60_000L
}
@@ -47,8 +47,14 @@ object DrainModel {
/** Estimates above this are implausible and suppressed. */
const val MAX_MINUTES = 24 * 60
/** Smoothing for the displayed minutes (higher = more responsive). */
const val MINUTES_ALPHA = 0.3f
/**
* Smoothing for the displayed minutes. Asymmetric on purpose: react quickly when the estimate
* DROPS (less time left — e.g. a degraded battery measured draining faster than its rating, or a
* first live fit undercutting the spec seed) so we don't keep showing more life than the latest
* reading supports, but ease UP slowly to swallow upward noise and stay conservative.
*/
const val MINUTES_ALPHA_DOWN = 0.6f
const val MINUTES_ALPHA_UP = 0.25f
/** Smoothing for the persisted per-mode learned rate. */
const val LEARN_ALPHA = 0.3f
@@ -103,9 +109,16 @@ object DrainModel {
return minutes.takeIf { it in 1..MAX_MINUTES }
}
/** Exponential moving average over the displayed minutes, to avoid a jumpy number. */
fun blendMinutes(previous: Int?, next: Int, alpha: Float = MINUTES_ALPHA): Int =
if (previous == null) next else (next * alpha + previous * (1f - alpha)).roundToInt()
/**
* Exponential moving average over the displayed minutes, to avoid a jumpy number. Uses the
* asymmetric [MINUTES_ALPHA_DOWN]/[MINUTES_ALPHA_UP] factors by default; pass an explicit [alpha]
* to force a symmetric factor (used by tests).
*/
fun blendMinutes(previous: Int?, next: Int, alpha: Float? = null): Int {
if (previous == null) return next
val a = alpha ?: if (next < previous) MINUTES_ALPHA_DOWN else MINUTES_ALPHA_UP
return (next * a + previous * (1f - a)).roundToInt()
}
/** Exponential moving average over the persisted learned rate across sessions. */
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
@@ -14,6 +14,7 @@ enum class PodModel(
@DrawableRes val leftPodIconRes: Int? = null,
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
val batterySpec: BatterySpec? = null,
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
@@ -26,6 +27,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -43,6 +45,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -64,6 +67,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -86,6 +90,7 @@ enum class PodModel(
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -117,6 +122,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -142,6 +148,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -175,6 +182,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -208,6 +216,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -242,6 +251,7 @@ enum class PodModel(
hasSleepDetection = true,
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -262,6 +272,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
modelNumbers = setOf("A2096"), // headphones
),
@@ -279,6 +290,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
modelNumbers = setOf("A3184"), // headphones
),
@@ -300,6 +312,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f),
modelNumbers = setOf("A3454"), // headphones
),
@@ -580,4 +593,18 @@ enum class PodModel(
*/
val hasDynamicEndOfCharge: Boolean = false,
)
/**
* Apple's published single-charge listening-time ratings, in hours, used to seed the battery
* time-remaining estimate before observed drain is available and as an upper bound on it. These
* are the POD-ONLY listening figures from Apple's tech-spec pages — NOT the "with charging case"
* aggregate. Left null for models Apple doesn't publish figures for (Beats, unknown), which simply
* fall back to purely observed drain. Figures verified against apple.com tech specs (2026-07).
*/
data class BatterySpec(
/** Listening hours with Active Noise Cancellation / Transparency active. */
val listeningHoursAncOn: Float? = null,
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
val listeningHoursAncOff: Float? = null,
)
}
@@ -38,6 +38,8 @@ data class AppleDeviceProfile(
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
/** Whether the dashboard battery time-remaining estimate is shown for this device. */
@SerialName("batteryEstimateEnabled") val batteryEstimateEnabled: Boolean = true,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -86,6 +88,7 @@ data class AppleDeviceProfile(
"notifyWhenCharged=$notifyWhenCharged, " +
"chargedThreshold=$chargedThreshold, " +
"chargedSlotScope=$chargedSlotScope, " +
"batteryEstimateEnabled=$batteryEstimateEnabled, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
+6 -2
View File
@@ -137,8 +137,12 @@
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
<string name="settings_overview_battery_estimate_label">Estimate time remaining</string>
<string name="settings_overview_battery_estimate_description">Show an estimated time until the earbuds run out, learned from how fast their battery drains. Even when enabled, it only appears after the battery has dropped enough to measure the drain — usually several minutes of active use, and not while charging.</string>
<string name="device_battery_estimate_toggle_label">Show time remaining</string>
<string name="device_battery_estimate_card_desc">Estimate how long until this device needs charging, from its rated battery life and how fast it actually drains. Shown on the dashboard.</string>
<string name="device_battery_estimate_reset_action">Reset learned data</string>
<string name="device_battery_estimate_reset_desc">Forget the measured drain and 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 what it learned about this device\'s battery drain and estimate from the rated battery life again, re-learning as you use it.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -12,6 +12,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
@@ -62,6 +63,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var batteryEstimator: BatteryEstimator
private lateinit var monitorModeResolver: MonitorModeResolver
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
@@ -116,6 +118,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
profilesRepo = mockk(relaxed = true) {
every { profiles } returns profilesFlow
}
batteryEstimator = mockk(relaxed = true)
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
monitorModeResolver = mockk<MonitorModeResolver>().also {
every { it.effectiveMode } returns effectiveModeFlow
@@ -140,6 +143,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
batteryEstimator = batteryEstimator,
monitorModeResolver = monitorModeResolver,
nudgeCapabilityStore = nudgeCapabilityStore,
timeSource = timeSource,
@@ -514,6 +518,45 @@ class DeviceSettingsViewModelTest : BaseTest() {
coVerify(exactly = 0) { aapManager.sendCommand(any(), any<AapCommand.SetSleepDetection>()) }
}
@Test
fun `state reflects the profile's batteryEstimateEnabled`() = runVmTest {
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryEstimateEnabled shouldBe false
}
@Test
fun `setBatteryEstimateEnabled updates the profile`() = runVmTest {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setBatteryEstimateEnabled(false)
coVerify { profilesRepo.updateAppleProfile(testAddress, any()) }
}
@Test
fun `resetBatteryEstimate resets the estimator for the profile`() = runVmTest {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.resetBatteryEstimate()
coVerify { batteryEstimator.reset(testAddress) }
}
@Test
fun `setSleepDetection(false) as non-Pro still sends command`() = runVmTest {
// Disabling must work regardless of pro status so users who enabled it
@@ -71,7 +71,6 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
private lateinit var fakeBatteryEstimateEnabled: FakeDataStoreValue<Boolean>
@BeforeEach
fun setup() {
@@ -87,7 +86,6 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -106,7 +104,6 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
every { it.batteryEstimateEnabled } returns fakeBatteryEstimateEnabled.mock
}
batteryEstimator = mockk<BatteryEstimator>().also {
@@ -650,4 +647,46 @@ class OverviewViewModelTest : BaseTest() {
latest!!.showTroubleshootSuggestion shouldBe false
}
}
@Test
fun `estimateFor is null when the device has the estimate disabled`() {
val device = mockk<PodDevice> {
every { batteryEstimateEnabled } returns false
every { isLive } returns true
every { profileId } returns "p1"
}
val state = estimateState(device, mapOf("p1" to sampleEstimate()))
state.estimateFor(device) shouldBe null
}
@Test
fun `estimateFor returns the estimate when enabled and live`() {
val device = mockk<PodDevice> {
every { batteryEstimateEnabled } returns true
every { isLive } returns true
every { profileId } returns "p1"
}
val estimate = sampleEstimate()
val state = estimateState(device, mapOf("p1" to estimate))
state.estimateFor(device) shouldBe estimate
}
private fun sampleEstimate() = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 120, fractionPerHour = 0.2f, source = BatteryEstimate.Source.LIVE),
)
private fun estimateState(device: PodDevice, estimates: Map<String, BatteryEstimate>) =
OverviewViewModel.State(
now = java.time.Instant.EPOCH,
permissions = emptySet(),
devices = listOf(device),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
)
}
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.TimeSource
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
@@ -10,6 +11,7 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,13 +33,21 @@ class BatteryEstimatorTest : BaseTest() {
left: Float?,
right: Float?,
charging: Boolean = false,
model: PodModel? = null,
estimateEnabled: Boolean = true,
): PodDevice {
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
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))
}
return PodDevice(profileId = profileId, ble = null, aap = AapPodState(batteries = batteries))
return PodDevice(
profileId = profileId,
ble = null,
aap = AapPodState(batteries = batteries),
profileModel = model,
batteryEstimateEnabled = estimateEnabled,
)
}
private fun estimator(
@@ -86,7 +96,8 @@ class BatteryEstimatorTest : BaseTest() {
}
@Test
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
fun `a charging device projects runtime from the learned rate`() = runTest(UnconfinedTestDispatcher()) {
// Docked/charging: no live drain, but we still show "what it'd last if used now" from history.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
val result = collectEstimate(
estimator(
@@ -94,7 +105,29 @@ class BatteryEstimatorTest : BaseTest() {
stored = stored,
)
)
result shouldBe emptyMap()
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@Test
fun `a charging device projects runtime from the model rating`() = runTest(UnconfinedTestDispatcher()) {
// Full AirPods Pro 2 in the case, nothing learned yet -> projects the 6h rating. 1.0 / (1/6) * 60.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a charging device with no rate shows nothing`() = runTest(UnconfinedTestDispatcher()) {
// Unknown model, nothing learned -> no basis to project from while charging.
collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.80f, right = 0.80f, charging = true))))
) shouldBe emptyMap()
}
@Test
@@ -109,7 +142,7 @@ class BatteryEstimatorTest : BaseTest() {
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe true
left.source shouldBe BatteryEstimate.Source.LEARNED
// 0.50 / 0.15 * 60 == 200
left.minutesRemaining shouldBe 200
}
@@ -122,7 +155,7 @@ class BatteryEstimatorTest : BaseTest() {
listOf(device("p1", left = level, right = level))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.isLearned shouldBe false
left.source shouldBe BatteryEstimate.Source.LIVE
}
@Test
@@ -134,12 +167,112 @@ class BatteryEstimatorTest : BaseTest() {
val estimate = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull()
val left = estimate.left.shouldNotBeNull()
val right = estimate.right.shouldNotBeNull()
left.isLearned shouldBe false
right.isLearned shouldBe false
left.source shouldBe BatteryEstimate.Source.LIVE
right.source shouldBe BatteryEstimate.Source.LIVE
// Faster-draining left pod must empty sooner than the right.
(left.minutesRemaining < right.minutesRemaining) shouldBe true
}
@Test
fun `a model rating seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
// One sample -> no live regression, nothing learned -> the AirPods Pro 2 rating (6h) seeds
// the estimate at once. 1.00 / (1/6) * 60 == 360.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `an unknown ANC mode seeds from the shorter rating`() = runTest(UnconfinedTestDispatcher()) {
// AirPods 4 ANC: 4h with ANC on, 5h off. The mode isn't known yet, so the shorter 4h rating
// is used to avoid over-promising. 1.00 / (1/4) * 60 == 240.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_GEN4_ANC))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 240
}
@Test
fun `the model rating caps an over-optimistic learned rate`() = runTest(UnconfinedTestDispatcher()) {
// A learned 0.10/hr implies 10h at full charge, beyond the Pro 2's 6h rating. The rating is a
// hard ceiling, so the shown estimate is capped at 6h (360), not 600.
val stored = mapOf(
"p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.10f), "UNKNOWN/RIGHT" to learned(0.10f)))
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 360
}
@Test
fun `a live rate slower than the rating is capped to the rating but stays LIVE`() = runTest(UnconfinedTestDispatcher()) {
// Measured 15%/hr on an AirPods Pro (rated 4.5h == ~22%/hr): draining slower than Apple rates,
// so the shown life is capped to the 4.5h rating. At 0.76 that's 0.76 / (1/4.5) * 60 == 205
// (not the ~304 the raw 15%/hr would imply). The estimate is still measured, so source == LIVE.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LIVE
left.minutesRemaining shouldBe 205
}
@Test
fun `an implausibly fast live rate is rejected in favour of the rating`() = runTest(UnconfinedTestDispatcher()) {
// 5%/4min == 75%/hr, far beyond 4x the Pro 2 rating (~67%/hr max plausible), so the live fit
// is discarded and the estimate falls back to the model rating.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.05f
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO2))
}
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `a device with the estimate disabled is not sampled`() = runTest(UnconfinedTestDispatcher()) {
// A clean steady discharge that WOULD yield a live estimate — but the feature is off.
val emissions = (0 until 5).map { i ->
val level = 0.80f - i * 0.01f
listOf(device("p1", left = level, right = level, estimateEnabled = false))
}
collectEstimate(estimator(emissions)) shouldBe emptyMap()
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
every { profiles } returns MutableStateFlow(
mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f))))
)
coEvery { save(any(), any()) } returns Unit
coEvery { delete(any()) } returns Unit
}
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(emptyList()) }
val timeSource = mockk<TimeSource> {
every { elapsedRealtime() } returns 0L
every { now() } returns now
}
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
estimator.reset("p1")
coVerify { drainStore.delete("p1") }
estimator.estimates.value.containsKey("p1") shouldBe false
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
@@ -94,6 +94,14 @@ class DrainModelTest : BaseTest() {
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
}
@Test
fun `blendMinutes reacts faster to drops than to rises`() {
// Drop (less time left): fast factor so we stop over-promising quickly. 0.6*180 + 0.4*360 = 252
DrainModel.blendMinutes(previous = 360, next = 180) shouldBe 252
// Rise (more time / noise): gentle factor so we don't jump up. 0.25*200 + 0.75*100 = 125
DrainModel.blendMinutes(previous = 100, next = 200) shouldBe 125
}
@Test
fun `blendRate seeds then smooths`() {
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
@@ -117,6 +117,44 @@ class ModelFeaturesTest : BaseTest() {
}
}
@Test
fun `battery specs are populated for exactly the Apple models`() {
PodModel.entries.filter { it.batterySpec != null }.toSet() shouldBe batterySpecModels
}
@Test
fun `ANC-capable models with a rating publish an ANC-on figure`() {
PodModel.entries
.filter { it.batterySpec != null && it.features.hasAncControl }
.forEach { model ->
withClue(model.name) {
(model.batterySpec?.listeningHoursAncOn != null) shouldBe true
}
}
}
@Test
fun `every battery spec has at least one rating`() {
PodModel.entries.mapNotNull { it.batterySpec }.forEach { spec ->
withClue(spec.toString()) {
(spec.listeningHoursAncOn != null || spec.listeningHoursAncOff != null) shouldBe true
}
}
}
@Test
fun `battery ratings are single-charge pod figures within a sane range`() {
// Guards against accidentally using Apple's "with charging case" aggregate (e.g. 30h).
PodModel.entries.forEach { model ->
val spec = model.batterySpec ?: return@forEach
listOfNotNull(spec.listeningHoursAncOn, spec.listeningHoursAncOff).forEach { hours ->
withClue("${model.name}: $hours") {
(hours in 1f..24f) shouldBe true
}
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
@@ -344,6 +382,21 @@ class ModelFeaturesTest : BaseTest() {
PodModel.POWERBEATS_PRO2,
)
private val batterySpecModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
@@ -27,6 +27,29 @@ class AppleDeviceProfileSerializationTest : BaseTest() {
profile.reactionConfig.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
}
@Test
fun `profiles stored before the battery estimate toggle default to enabled`() {
val legacyJson = """
{
"id": "test-id",
"label": "My Pods"
}
""".trimIndent()
val profile = json.decodeFromString<AppleDeviceProfile>(legacyJson)
profile.batteryEstimateEnabled shouldBe true
}
@Test
fun `battery estimate toggle round-trips`() {
val profile = AppleDeviceProfile(label = "My Pods", batteryEstimateEnabled = false)
val decoded = json.decodeFromString<AppleDeviceProfile>(json.encodeToString(profile))
decoded.batteryEstimateEnabled shouldBe false
}
@Test
fun `charged reaction settings round-trip`() {
val profile = AppleDeviceProfile(