From 8c3badb2b922aac1d0aa2724cfc91653533fc327 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 01:33:24 +0200 Subject: [PATCH] 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 --- .../darken/capod/main/core/GeneralSettings.kt | 2 - .../ui/devicesettings/DeviceSettingsScreen.kt | 34 ++-- .../devicesettings/DeviceSettingsViewModel.kt | 21 ++- .../ui/devicesettings/cards/BatteryCard.kt | 160 ++++++++++++++++-- .../ui/devicesettings/cards/ReactionsCard.kt | 52 ------ .../capod/main/ui/overview/OverviewScreen.kt | 6 +- .../main/ui/overview/OverviewViewModel.kt | 13 +- .../main/ui/overview/cards/DualPodsCard.kt | 29 +++- .../main/ui/overview/cards/SinglePodsCard.kt | 2 +- .../settings/general/GeneralSettingsScreen.kt | 21 --- .../general/GeneralSettingsViewModel.kt | 12 +- .../capod/monitor/core/DeviceMonitor.kt | 4 + .../eu/darken/capod/monitor/core/PodDevice.kt | 2 + .../monitor/core/battery/BatteryDrainStore.kt | 7 +- .../monitor/core/battery/BatteryEstimate.kt | 23 ++- .../monitor/core/battery/BatteryEstimator.kt | 126 ++++++++++++-- .../capod/monitor/core/battery/DrainModel.kt | 23 ++- .../darken/capod/pods/core/apple/PodModel.kt | 27 +++ .../capod/profiles/core/AppleDeviceProfile.kt | 3 + app/src/main/res/values/strings.xml | 8 +- .../DeviceSettingsViewModelTest.kt | 43 +++++ .../main/ui/overview/OverviewViewModelTest.kt | 45 ++++- .../core/battery/BatteryEstimatorTest.kt | 147 +++++++++++++++- .../monitor/core/battery/DrainModelTest.kt | 8 + .../pods/core/apple/ModelFeaturesTest.kt | 53 ++++++ .../AppleDeviceProfileSerializationTest.kt | 23 +++ 26 files changed, 718 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index d5bb4802..aa53d736 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index dd1f9f85..54a8f887 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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()) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index adbc66f0..fe396368 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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 val nudgeAvailability = args[7] as NudgeAvailability - val stemActions = profiles.filterIsInstance() + val appleProfile = profiles.filterIsInstance() .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()" } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt index d73ce691..49ab4c95 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt @@ -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, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt index 9da5cd49..494a4ad4 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 02c3af8b..0ad67d7b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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), ), ), ), diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index fa3785cd..843b57b3 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -88,7 +88,6 @@ class OverviewViewModel @Inject constructor( val reactionsHintDismissed: Boolean, val hideUnmatchedDevices: Boolean, val showTroubleshootSuggestion: Boolean, - val batteryEstimateEnabled: Boolean, val batteryEstimates: Map, ) @@ -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 = 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] diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index d8ba0828..9d094f05 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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 = {}, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index a1025e50..17e9e59b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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 = {}, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt index ce8b364f..c58c79bc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt @@ -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 = {}, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt index 741ff1bb..4fe83be6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt @@ -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(showNotif as Any, keepNotif as Any, batteryEstimate as Any) + arrayOf(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()) { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 11ce913a..1e875c21 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -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, ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 29b7c7c2..726063be 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -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, ) { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryDrainStore.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryDrainStore.kt index 91763cc5..b9bac12d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryDrainStore.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryDrainStore.kt @@ -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 } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt index 06a97051..42859b5c 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimate.kt @@ -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 } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt index 44c2f2be..05b315b1 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimator.kt @@ -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() + // 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 = 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) { - // 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) = 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 } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt index 64390b76..45c3f0e9 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt @@ -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 = diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index 4317cfb6..f71d71b9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -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, + ) } diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index fd53ddbb..98ca125c 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -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" + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 187557cf..600610fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -137,8 +137,12 @@ Hide unmatched devices Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods. - Estimate time remaining - 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. + Show time remaining + Estimate how long until this device needs charging, from its rated battery life and how fast it actually drains. Shown on the dashboard. + Reset learned data + Forget the measured drain and start over from the rated battery life. + Reset learned data? + 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. Acknowledgements Automatic bug reports diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index b3185837..31cecfc0 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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 @@ -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().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()) } } + @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 diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 9d7fb735..e85bf8c1 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -71,7 +71,6 @@ class OverviewViewModelTest : BaseTest() { private lateinit var effectiveModeFlow: MutableStateFlow private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue - private lateinit var fakeBatteryEstimateEnabled: FakeDataStoreValue @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().also { every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock - every { it.batteryEstimateEnabled } returns fakeBatteryEstimateEnabled.mock } batteryEstimator = mockk().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 { + 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 { + 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) = + 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, + ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt index 345cffe1..817a753e 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimatorTest.kt @@ -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 { + 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 { every { devices } returns flowOf(emptyList()) } + val timeSource = mockk { + 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, diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt index 42e31c92..1e4c5b51 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/DrainModelTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt index 2db48c97..a226207c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt @@ -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.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), diff --git a/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt b/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt index d84ecfa8..31a74fde 100644 --- a/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt +++ b/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt @@ -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(legacyJson) + + profile.batteryEstimateEnabled shouldBe true + } + + @Test + fun `battery estimate toggle round-trips`() { + val profile = AppleDeviceProfile(label = "My Pods", batteryEstimateEnabled = false) + + val decoded = json.decodeFromString(json.encodeToString(profile)) + + decoded.batteryEstimateEnabled shouldBe false + } + @Test fun `charged reaction settings round-trip`() { val profile = AppleDeviceProfile(