mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat: Add earbud battery time-remaining estimate
This commit is contained in:
@@ -212,6 +212,37 @@ object MockPodDataProvider {
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
|
||||
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
|
||||
*/
|
||||
fun dualPodFullyLoaded(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-full",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ON,
|
||||
supported = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
),
|
||||
),
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
),
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
|
||||
pod = AapSetting.PrimaryPod.Pod.LEFT,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
||||
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-reactions",
|
||||
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockSingleAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockDualAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
|
||||
@@ -85,6 +85,8 @@ 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,
|
||||
|
||||
@@ -65,6 +65,7 @@ import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
@@ -333,6 +334,7 @@ fun OverviewScreen(
|
||||
showDebug = state.isDebug,
|
||||
now = state.now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = state.estimateFor(device),
|
||||
onToggleCollapse = if (isToggleable) {
|
||||
{ onToggleDeviceExpansion(device) }
|
||||
} else null,
|
||||
@@ -407,6 +409,7 @@ private fun PodDeviceCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
@@ -417,6 +420,7 @@ private fun PodDeviceCard(
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -426,6 +430,7 @@ private fun PodDeviceCard(
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -444,7 +449,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.dualPodFullyLoaded(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
@@ -457,6 +462,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
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),
|
||||
),
|
||||
"preview-single" to BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
|
||||
@@ -23,6 +23,8 @@ import eu.darken.capod.main.core.PermissionTool
|
||||
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.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.tierRank
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
@@ -59,6 +61,7 @@ class OverviewViewModel @Inject constructor(
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val batteryEstimator: BatteryEstimator,
|
||||
private val timeSource: TimeSource,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
@@ -85,6 +88,8 @@ class OverviewViewModel @Inject constructor(
|
||||
val reactionsHintDismissed: Boolean,
|
||||
val hideUnmatchedDevices: Boolean,
|
||||
val showTroubleshootSuggestion: Boolean,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val batteryEstimates: Map<String, BatteryEstimate>,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -120,8 +125,16 @@ class OverviewViewModel @Inject constructor(
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
generalSettings.hideUnmatchedDevices.flow,
|
||||
troubleshootSuggestion,
|
||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
|
||||
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
|
||||
generalSettings.batteryEstimateEnabled.flow,
|
||||
batteryEstimator.estimates,
|
||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimateEnabled, batteryEstimates ->
|
||||
OverviewUiSettings(
|
||||
reactionsHintDismissed = reactionsHintDismissed,
|
||||
hideUnmatchedDevices = hideUnmatched,
|
||||
showTroubleshootSuggestion = showTroubleshootSuggestion,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
batteryEstimates = batteryEstimates,
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -210,6 +223,8 @@ class OverviewViewModel @Inject constructor(
|
||||
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
||||
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
||||
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||
batteryEstimateEnabled = uiSettings.batteryEstimateEnabled,
|
||||
batteryEstimates = uiSettings.batteryEstimates,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -228,9 +243,22 @@ 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.
|
||||
*/
|
||||
fun estimateFor(device: PodDevice): BatteryEstimate? {
|
||||
if (!batteryEstimateEnabled) return null
|
||||
if (!device.isLive) return null
|
||||
val profileId = device.profileId ?: return null
|
||||
return batteryEstimates[profileId]
|
||||
}
|
||||
|
||||
/** Profile list order used as tiebreaker within each connection tier. */
|
||||
private val profileOrder: Map<String, Int> by lazy {
|
||||
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||
|
||||
@@ -60,12 +60,14 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import java.time.Instant
|
||||
|
||||
@@ -76,6 +78,7 @@ fun DualPodsCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onUpgrade: (() -> Unit)? = null,
|
||||
@@ -192,6 +195,7 @@ fun DualPodsCard(
|
||||
device = device,
|
||||
showDebug = showDebug,
|
||||
now = now,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onAncModeChange = onAncModeChange,
|
||||
)
|
||||
}
|
||||
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
batteryEstimate: BatteryEstimate?,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Circular battery gauges side by side
|
||||
@@ -233,6 +239,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
)
|
||||
|
||||
PodGauge(
|
||||
@@ -245,6 +252,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +307,7 @@ private fun PodGauge(
|
||||
isMicrophone: Boolean,
|
||||
showMicrophone: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
timeRemaining: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||
@@ -356,16 +365,27 @@ private fun PodGauge(
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
// Battery percentage, with the time-remaining estimate inline next to it
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
if (timeRemaining != null) {
|
||||
Text(
|
||||
text = "· $timeRemaining",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -454,10 +474,32 @@ private fun CaseRow(
|
||||
@Composable
|
||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||
showDebug = false,
|
||||
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),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
|
||||
// isLearned = true: 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),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Instant
|
||||
@@ -73,6 +75,7 @@ fun SinglePodsCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onUpgrade: (() -> Unit)? = null,
|
||||
@@ -178,6 +181,7 @@ fun SinglePodsCard(
|
||||
device = device,
|
||||
showDebug = showDebug,
|
||||
now = now,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onAncModeChange = onAncModeChange,
|
||||
)
|
||||
}
|
||||
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
batteryEstimate: BatteryEstimate?,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
}
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
// Battery percentage + time remaining, stacked inside the ring
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
val headsetEstimate = batteryEstimate?.headset
|
||||
if (headsetEstimate != null) {
|
||||
Text(
|
||||
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -333,6 +350,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
|
||||
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitored(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
batteryEstimate = BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, isLearned = true),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -60,6 +61,7 @@ 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) },
|
||||
@@ -78,6 +80,7 @@ fun GeneralSettingsScreen(
|
||||
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
||||
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
||||
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
|
||||
onBatteryEstimateEnabledChanged: (Boolean) -> Unit,
|
||||
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
||||
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
||||
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
||||
@@ -215,6 +218,21 @@ 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))
|
||||
}
|
||||
@@ -286,6 +304,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
|
||||
isOffloadedBatchingDisabled = false,
|
||||
useIndirectScanResultCallback = false,
|
||||
hideUnmatchedDevices = false,
|
||||
batteryEstimateEnabled = true,
|
||||
themeState = ThemeState(),
|
||||
)
|
||||
|
||||
@@ -301,6 +320,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
onHideUnmatchedDevicesChanged = {},
|
||||
onBatteryEstimateEnabledChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,5 +336,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
onHideUnmatchedDevicesChanged = {},
|
||||
onBatteryEstimateEnabledChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
+10
-2
@@ -35,6 +35,7 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
val isOffloadedBatchingDisabled: Boolean,
|
||||
val useIndirectScanResultCallback: Boolean,
|
||||
val hideUnmatchedDevices: Boolean,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val themeState: ThemeState,
|
||||
)
|
||||
|
||||
@@ -44,9 +45,10 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
combine(
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { showNotif, keepNotif ->
|
||||
generalSettings.batteryEstimateEnabled.flow,
|
||||
) { showNotif, keepNotif, batteryEstimate ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(showNotif as Any, keepNotif as Any, batteryEstimate as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -68,6 +70,7 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
hideUnmatchedDevices = hideUnmatched,
|
||||
batteryEstimateEnabled = general[2] as Boolean,
|
||||
themeState = themeState,
|
||||
)
|
||||
}.asLiveState()
|
||||
@@ -102,6 +105,11 @@ 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()) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
|
||||
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
|
||||
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
|
||||
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
|
||||
*/
|
||||
@Singleton
|
||||
class BatteryDrainStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
private val storeDir by lazy {
|
||||
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
|
||||
}
|
||||
private val lock = Mutex()
|
||||
|
||||
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
|
||||
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
|
||||
|
||||
init {
|
||||
appScope.launch { loadAll() }
|
||||
}
|
||||
|
||||
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val dir = storeDir
|
||||
if (!dir.exists()) return@withLock
|
||||
|
||||
val loaded = mutableMapOf<ProfileId, DrainProfile>()
|
||||
dir.listFiles()
|
||||
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
|
||||
?.forEach { file ->
|
||||
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
|
||||
try {
|
||||
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
|
||||
_profiles.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
|
||||
|
||||
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
|
||||
val file = id.toFile()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
try {
|
||||
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
|
||||
if (!tmp.renameTo(file)) {
|
||||
// renameTo can fail across some filesystems; fall back to a direct write.
|
||||
file.writeText(tmp.readText())
|
||||
tmp.delete()
|
||||
}
|
||||
_profiles.value += (id to profile)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
|
||||
id.toFile().delete()
|
||||
_profiles.value -= id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
|
||||
storeDir.listFiles()?.forEach { it.delete() }
|
||||
_profiles.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "BatteryDrainStore")
|
||||
private const val PREFIX = "drainrate_"
|
||||
private const val SUFFIX = ".json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
/**
|
||||
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
|
||||
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
|
||||
* [right] for dual-pod devices. Slots with no usable estimate are null.
|
||||
*/
|
||||
data class BatteryEstimate(
|
||||
val left: Pod? = null,
|
||||
val right: Pod? = null,
|
||||
val headset: Pod? = null,
|
||||
) {
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
data class Pod(
|
||||
val minutesRemaining: Int,
|
||||
val fractionPerHour: Float,
|
||||
val isLearned: Boolean,
|
||||
)
|
||||
|
||||
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
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.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Learns each device's battery drain rate from observed levels over time and turns it into a
|
||||
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
|
||||
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
|
||||
* is available immediately on reconnect.
|
||||
*
|
||||
* Lifecycle: [monitor] is launched by the foreground monitor service (see
|
||||
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
|
||||
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
|
||||
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
|
||||
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
|
||||
*/
|
||||
@Singleton
|
||||
class BatteryEstimator @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val drainStore: BatteryDrainStore,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
|
||||
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
|
||||
|
||||
private enum class Slot { LEFT, RIGHT, HEADSET }
|
||||
|
||||
private class SlotHistory {
|
||||
private val samples = ArrayDeque<DrainSample>()
|
||||
|
||||
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
|
||||
val size: Int get() = samples.size
|
||||
|
||||
fun record(sample: DrainSample) {
|
||||
samples.addLast(sample)
|
||||
while (samples.size > RING_SIZE) samples.removeFirst()
|
||||
}
|
||||
|
||||
fun clear() = samples.clear()
|
||||
|
||||
fun toList(): List<DrainSample> = samples.toList()
|
||||
}
|
||||
|
||||
private class DeviceTracker {
|
||||
var modeBucket: String = MODE_UNKNOWN
|
||||
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||
|
||||
/** Smoothed displayed minutes, per pod. */
|
||||
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
|
||||
var lastUpdateMs: Long? = null
|
||||
|
||||
// Keyed by "<bucket>/<slot>".
|
||||
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
|
||||
* during a single session blend against a fixed baseline instead of runaway-converging.
|
||||
*/
|
||||
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
|
||||
|
||||
fun clearSlots() = slots.values.forEach { it.clear() }
|
||||
|
||||
fun resetWindow() {
|
||||
clearSlots()
|
||||
lastMinutes.clear()
|
||||
sessionBaseline.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
|
||||
|
||||
fun monitor(): Flow<Unit> = deviceMonitor.devices
|
||||
.onEach { devices -> process(devices) }
|
||||
.onCompletion {
|
||||
// The collector stops with the monitor service; drop session state so the UI can't
|
||||
// keep showing a stale estimate and the next session re-seeds from persistence.
|
||||
trackers.clear()
|
||||
_estimates.value = emptyMap()
|
||||
}
|
||||
.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.
|
||||
val unambiguous = devices
|
||||
.filter { it.profileId != null && it.isLive }
|
||||
.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).
|
||||
next.keys.retainAll(unambiguous.keys)
|
||||
|
||||
for ((profileId, device) in unambiguous) {
|
||||
val estimate = updateTracker(profileId, device)
|
||||
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
|
||||
}
|
||||
|
||||
_estimates.value = next
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice): BatteryEstimate? {
|
||||
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
|
||||
val nowMs = timeSource.elapsedRealtime()
|
||||
val bucket = device.modeBucket()
|
||||
|
||||
// A long gap since the last update means the device was out of range / reconnected — the
|
||||
// prior window is stale and must not be extended across the absence.
|
||||
val gap = tracker.lastUpdateMs?.let { nowMs - it }
|
||||
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
|
||||
tracker.lastUpdateMs = nowMs
|
||||
|
||||
// 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)
|
||||
tracker.resetWindow()
|
||||
tracker.modeBucket = bucket
|
||||
}
|
||||
|
||||
for (slot in Slot.entries) {
|
||||
val history = tracker.slots.getValue(slot)
|
||||
val charging = device.liveCharging(slot)
|
||||
val fraction = device.liveFraction(slot)
|
||||
|
||||
when {
|
||||
charging == true -> { // charging → battery is rising, not draining
|
||||
history.clear()
|
||||
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop this pod's smoothing
|
||||
}
|
||||
fraction == null -> history.clear() // unavailable reading → just drop this slot's window
|
||||
else -> {
|
||||
val last = history.lastFraction
|
||||
when {
|
||||
last == null -> history.record(DrainSample(nowMs, fraction))
|
||||
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
|
||||
history.clear()
|
||||
history.record(DrainSample(nowMs, fraction))
|
||||
tracker.lastMinutes.remove(slot)
|
||||
}
|
||||
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
|
||||
else -> Unit // ~unchanged, no new information
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
persistFromWindow(profileId, tracker, bucket, nowMs, force = false)
|
||||
return computeEstimate(profileId, tracker, device, bucket)
|
||||
}
|
||||
|
||||
/** Computes an independent estimate for each pod (left / right / headset). */
|
||||
private fun computeEstimate(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
bucket: String,
|
||||
): BatteryEstimate? {
|
||||
val estimate = BatteryEstimate(
|
||||
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT),
|
||||
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT),
|
||||
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET),
|
||||
)
|
||||
return estimate.takeIf { it.hasAny }
|
||||
}
|
||||
|
||||
private fun slotEstimate(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
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 smoothed = DrainModel.blendMinutes(tracker.lastMinutes[slot], minutes)
|
||||
tracker.lastMinutes[slot] = smoothed
|
||||
return BatteryEstimate.Pod(
|
||||
minutesRemaining = smoothed,
|
||||
fractionPerHour = rate,
|
||||
isLearned = liveRate == null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists each pod's live drain rate under its (bucket, slot) key, at most once per
|
||||
* [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence) unless [force]d (mode change).
|
||||
*/
|
||||
private suspend fun persistFromWindow(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
bucket: String,
|
||||
nowMs: Long,
|
||||
force: Boolean,
|
||||
) {
|
||||
val existing = drainStore.profiles.value[profileId] ?: DrainProfile()
|
||||
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
|
||||
|
||||
val key = rateKey(bucket, slot)
|
||||
val lastPersist = tracker.lastPersistAtMs[key]
|
||||
if (!force && lastPersist != null && nowMs - lastPersist < PERSIST_INTERVAL_MS) continue
|
||||
tracker.lastPersistAtMs[key] = nowMs
|
||||
|
||||
// Blend against the rate stored when this session began, captured once, so a single long
|
||||
// session's repeated writes can't dominate prior history by re-blending their own output.
|
||||
if (!tracker.sessionBaseline.containsKey(key)) {
|
||||
tracker.sessionBaseline[key] = rates[key]?.fractionPerHour
|
||||
}
|
||||
val blended = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate)
|
||||
rates = rates + (key to DrainProfile.LearnedRate(
|
||||
fractionPerHour = blended,
|
||||
sampleCount = history.size,
|
||||
updatedAt = timeSource.now(),
|
||||
))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(blended)}/hr" }
|
||||
}
|
||||
|
||||
if (changed) drainStore.save(profileId, existing.copy(rates = rates))
|
||||
}
|
||||
|
||||
private fun learnedRate(profileId: ProfileId, bucket: String, slot: Slot): Float? {
|
||||
val profile = drainStore.profiles.value[profileId] ?: return null
|
||||
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour
|
||||
}
|
||||
|
||||
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
|
||||
|
||||
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
|
||||
|
||||
// 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? {
|
||||
val value = when (slot) {
|
||||
Slot.LEFT -> aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
Slot.RIGHT -> aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
Slot.HEADSET -> aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
}
|
||||
return value?.takeIf { isKnownBattery(it) }
|
||||
}
|
||||
|
||||
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
|
||||
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
|
||||
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
|
||||
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "BatteryEstimator")
|
||||
private const val RING_SIZE = 32
|
||||
private const val EPSILON = 0.001f
|
||||
private const val MODE_UNKNOWN = "UNKNOWN"
|
||||
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
|
||||
|
||||
/** A gap longer than this between updates means the device was away — reset its window. */
|
||||
private const val STALE_GAP_MS = 15 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A single battery observation for one slot (left / right / headset).
|
||||
*
|
||||
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
|
||||
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
|
||||
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
|
||||
*/
|
||||
data class DrainSample(
|
||||
val atElapsedMs: Long,
|
||||
val fraction: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
|
||||
* be unit-tested without Android, coroutines, or persistence.
|
||||
*
|
||||
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
|
||||
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
|
||||
*/
|
||||
object DrainModel {
|
||||
|
||||
/** Minimum samples before a live regression is trusted. */
|
||||
const val MIN_SAMPLES = 4
|
||||
|
||||
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
|
||||
const val MIN_SPAN_MS = 3 * 60_000L
|
||||
|
||||
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
|
||||
const val MIN_TOTAL_DROP = 0.03f
|
||||
|
||||
/**
|
||||
* Only samples within this window of the newest one feed the regression. Drops stale points
|
||||
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
|
||||
* hours of absence.
|
||||
*/
|
||||
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
|
||||
|
||||
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
|
||||
const val RATE_MIN = 0.02f
|
||||
const val RATE_MAX = 0.80f
|
||||
|
||||
/** 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 persisted per-mode learned rate. */
|
||||
const val LEARN_ALPHA = 0.3f
|
||||
|
||||
/**
|
||||
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
|
||||
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
|
||||
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
|
||||
*/
|
||||
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
|
||||
if (samples.size < MIN_SAMPLES) return null
|
||||
|
||||
// Restrict to the recent window so a gap before the newest sample can't span the fit.
|
||||
val newestMs = samples.last().atElapsedMs
|
||||
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
|
||||
if (recent.size < MIN_SAMPLES) return null
|
||||
|
||||
val first = recent.first()
|
||||
val last = recent.last()
|
||||
if (last.atElapsedMs - first.atElapsedMs < MIN_SPAN_MS) return null
|
||||
if (first.fraction - last.fraction < MIN_TOTAL_DROP) return null
|
||||
|
||||
val n = recent.size.toDouble()
|
||||
var sumX = 0.0
|
||||
var sumY = 0.0
|
||||
var sumXY = 0.0
|
||||
var sumXX = 0.0
|
||||
for (s in recent) {
|
||||
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
|
||||
val y = s.fraction.toDouble()
|
||||
sumX += x
|
||||
sumY += y
|
||||
sumXY += x * y
|
||||
sumXX += x * x
|
||||
}
|
||||
val denominator = n * sumXX - sumX * sumX
|
||||
if (abs(denominator) < 1e-9) return null
|
||||
|
||||
// Negative slope == draining; flip to a positive drain rate.
|
||||
val rate = (-((n * sumXY - sumX * sumY) / denominator)).toFloat()
|
||||
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
|
||||
return rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
|
||||
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
|
||||
*/
|
||||
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
|
||||
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
|
||||
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
|
||||
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 persisted learned rate across sessions. */
|
||||
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
|
||||
if (previous == null) next else next * alpha + previous * (1f - alpha)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
|
||||
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
|
||||
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
|
||||
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
|
||||
*/
|
||||
@Serializable
|
||||
data class DrainProfile(
|
||||
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
|
||||
) {
|
||||
@Serializable
|
||||
data class LearnedRate(
|
||||
/** Drain rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
|
||||
@SerialName("fractionPerHour") val fractionPerHour: Float,
|
||||
@SerialName("sampleCount") val sampleCount: Int,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("updatedAt") val updatedAt: Instant,
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
@@ -87,6 +88,7 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
||||
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -358,6 +360,11 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
batteryEstimator.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
|
||||
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
|
||||
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||
else context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
|
||||
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val mins = minutes % 60
|
||||
return when {
|
||||
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
|
||||
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
|
||||
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
|
||||
}
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float): Int = when {
|
||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val settings: DeviceProfilesSettings,
|
||||
private val deviceStateCache: DeviceStateCache,
|
||||
private val batteryDrainStore: BatteryDrainStore,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
|
||||
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
|
||||
log(VERBOSE) { "Removed device profile with ID: $profileId" }
|
||||
deviceStateCache.delete(profileId)
|
||||
batteryDrainStore.delete(profileId)
|
||||
}
|
||||
|
||||
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
|
||||
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
suspend fun clear() = mutex.withLock {
|
||||
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
|
||||
deviceStateCache.deleteAll()
|
||||
batteryDrainStore.deleteAll()
|
||||
}
|
||||
|
||||
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
|
||||
<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="settings_acknowledgements_label">Acknowledgements</string>
|
||||
|
||||
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
|
||||
@@ -310,6 +312,9 @@
|
||||
<string name="last_seen_x">Last seen: %s</string>
|
||||
<string name="first_seen_x">First seen: %s</string>
|
||||
<string name="battery_cached_label">Last known \u00B7 %s</string>
|
||||
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||
<string name="permission_post_notifications_label">Show notifications</string>
|
||||
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import eu.darken.capod.main.core.PermissionTool
|
||||
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.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
@@ -56,6 +58,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
private lateinit var bluetoothManager: BluetoothManager2
|
||||
private lateinit var profilesRepo: DeviceProfilesRepo
|
||||
private lateinit var monitorModeResolver: MonitorModeResolver
|
||||
private lateinit var batteryEstimator: BatteryEstimator
|
||||
private val timeSource: TimeSource = TestTimeSource()
|
||||
|
||||
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
|
||||
@@ -68,6 +71,7 @@ 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() {
|
||||
@@ -83,6 +87,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
|
||||
fakeReactionsHintDismissed = FakeDataStoreValue(false)
|
||||
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
|
||||
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
|
||||
Bugs.isDebug.value = false
|
||||
|
||||
monitorControl = mockk(relaxed = true)
|
||||
@@ -101,6 +106,11 @@ 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 {
|
||||
every { it.estimates } returns MutableStateFlow(emptyMap<String, BatteryEstimate>())
|
||||
}
|
||||
|
||||
monitorModeResolver = mockk<MonitorModeResolver>().also {
|
||||
@@ -139,6 +149,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
profilesRepo = profilesRepo,
|
||||
aapManager = mockk(relaxed = true),
|
||||
monitorModeResolver = monitorModeResolver,
|
||||
batteryEstimator = batteryEstimator,
|
||||
timeSource = timeSource,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
|
||||
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.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
|
||||
class BatteryEstimatorTest : BaseTest() {
|
||||
|
||||
private val now = Instant.parse("2026-04-02T12:00:00Z")
|
||||
|
||||
private fun device(
|
||||
profileId: String?,
|
||||
left: Float?,
|
||||
right: Float?,
|
||||
charging: Boolean = false,
|
||||
): 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))
|
||||
}
|
||||
|
||||
private fun estimator(
|
||||
emissions: List<List<PodDevice>>,
|
||||
stored: Map<String, DrainProfile> = emptyMap(),
|
||||
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
|
||||
): BatteryEstimator {
|
||||
val deviceMonitor = mockk<DeviceMonitor> {
|
||||
every { devices } returns flowOf(*emissions.toTypedArray())
|
||||
}
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(stored)
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
}
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returnsMany clockMs
|
||||
every { now() } returns now
|
||||
}
|
||||
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the estimator over its (finite) device flow and returns the last non-empty estimate map
|
||||
* seen *during* collection. monitor() clears estimates on completion (it stops with the service),
|
||||
* so we capture the live value as it is produced rather than reading it after the flow ends.
|
||||
*/
|
||||
private suspend fun TestScope.collectEstimate(estimator: BatteryEstimator): Map<String, BatteryEstimate> {
|
||||
val captured = mutableListOf<Map<String, BatteryEstimate>>()
|
||||
backgroundScope.launch { estimator.estimates.collect { captured += it } }
|
||||
estimator.monitor().collect {}
|
||||
return captured.lastOrNull { it.isNotEmpty() } ?: emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached-only device is not sampled`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// ble == null && aap == null -> not live -> ignored.
|
||||
val offline = PodDevice(profileId = "p1", ble = null, aap = null)
|
||||
collectEstimate(estimator(listOf(listOf(offline)))) shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous same-profile devices are skipped`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val a = device("p1", left = 0.80f, right = 0.80f)
|
||||
val b = device("p1", left = 0.50f, right = 0.50f)
|
||||
collectEstimate(estimator(listOf(listOf(a, b)))) shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
|
||||
val result = collectEstimate(
|
||||
estimator(
|
||||
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true))),
|
||||
stored = stored,
|
||||
)
|
||||
)
|
||||
result shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a learned rate seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// One emission, only one sample -> no live regression -> must fall back to learned rate.
|
||||
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
|
||||
val result = collectEstimate(
|
||||
estimator(
|
||||
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f))),
|
||||
stored = stored,
|
||||
)
|
||||
)
|
||||
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.isLearned shouldBe true
|
||||
// 0.50 / 0.15 * 60 == 200
|
||||
left.minutesRemaining shouldBe 200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steady discharge yields a live estimate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// 5 snapshots, 1% lost every 4 minutes -> 15%/hr -> 0.15 fraction/hr.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level))
|
||||
}
|
||||
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.isLearned shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pods draining at different rates get independent estimates`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Left drains faster (1.25%/step) than right (1%/step) over the same 4-minute steps.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
listOf(device("p1", left = 0.80f - i * 0.0125f, right = 0.80f - i * 0.01f))
|
||||
}
|
||||
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
|
||||
// Faster-draining left pod must empty sooner than the right.
|
||||
(left.minutesRemaining < right.minutesRemaining) shouldBe true
|
||||
}
|
||||
|
||||
private fun learned(rate: Float) = DrainProfile.LearnedRate(
|
||||
fractionPerHour = rate,
|
||||
sampleCount = 5,
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import io.kotest.matchers.floats.plusOrMinus
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class DrainModelTest : BaseTest() {
|
||||
|
||||
/** Samples draining at a constant rate, [perMinute] fraction lost per minute. */
|
||||
private fun drainingSamples(
|
||||
start: Float,
|
||||
perMinute: Float,
|
||||
count: Int,
|
||||
stepMinutes: Long = 4,
|
||||
): List<DrainSample> = (0 until count).map { i ->
|
||||
DrainSample(
|
||||
atElapsedMs = i * stepMinutes * 60_000L,
|
||||
fraction = start - perMinute * (i * stepMinutes),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slope recovers a constant drain rate in fraction per hour`() {
|
||||
// 0.25% per minute == 15% per hour == 0.15 fraction/hour.
|
||||
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))
|
||||
rate.shouldNotBeNull()
|
||||
rate shouldBe (0.15f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rate is a fraction not a percentage`() {
|
||||
// Sanity guard against a 100x unit error: a ~15%/hr drain must be ~0.15, never ~15.
|
||||
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 6))!!
|
||||
(rate < 1f) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too few samples yields null`() {
|
||||
DrainModel.slopeFractionPerHour(drainingSamples(0.80f, 0.0025f, count = 3)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a too-short window is rejected`() {
|
||||
// 4 samples 30s apart: enough points, but span < MIN_SPAN_MS.
|
||||
val samples = (0 until 4).map { DrainSample(it * 30_000L, 0.80f - it * 0.01f) }
|
||||
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negligible drop is rejected`() {
|
||||
// Long enough window but total drop below MIN_TOTAL_DROP.
|
||||
val samples = (0 until 5).map { DrainSample(it * 5 * 60_000L, 0.80f - it * 0.002f) }
|
||||
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a charging-style increase is not a drain`() {
|
||||
val samples = (0 until 5).map { DrainSample(it * 4 * 60_000L, 0.50f + it * 0.02f) }
|
||||
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an implausibly fast drain is rejected`() {
|
||||
// 4 rapid 1% ticks spanning > MIN_SPAN but dropping far too fast (~120%/hr).
|
||||
val samples = (0 until 5).map { DrainSample(it * 60_000L, 0.80f - it * 0.02f) }
|
||||
.let { it + DrainSample(it.size * 60_000L, 0.70f) } // keep span > 3 min
|
||||
DrainModel.slopeFractionPerHour(samples).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesRemaining divides level by rate`() {
|
||||
// 50% left at 0.15/hr -> 0.5 / 0.15 * 60 = 200 minutes.
|
||||
DrainModel.minutesRemaining(0.50f, 0.15f) shouldBe 200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesRemaining rejects a non-positive rate`() {
|
||||
DrainModel.minutesRemaining(0.50f, 0f).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesRemaining suppresses absurd estimates`() {
|
||||
// Extremely slow rate -> beyond MAX_MINUTES -> suppressed.
|
||||
DrainModel.minutesRemaining(1.0f, 0.0001f).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blendMinutes seeds then smooths`() {
|
||||
DrainModel.blendMinutes(previous = null, next = 100) shouldBe 100
|
||||
// 0.3 * 200 + 0.7 * 100 = 130
|
||||
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blendRate seeds then smooths`() {
|
||||
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
|
||||
DrainModel.blendRate(previous = 0.1f, next = 0.2f, alpha = 0.3f) shouldBe (0.13f plusOrMinus 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regression denominator is well conditioned for spread samples`() {
|
||||
// Guard that real spread input produces a finite, positive rate (no divide-by-zero path).
|
||||
val rate = DrainModel.slopeFractionPerHour(drainingSamples(0.90f, 0.003f, count = 8))!!
|
||||
(rate.isFinite() && rate > 0f) shouldBe true
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
|
||||
generalSettings = mockk(relaxed = true),
|
||||
settings = settings,
|
||||
deviceStateCache = mockk(relaxed = true),
|
||||
batteryDrainStore = mockk(relaxed = true),
|
||||
json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user