From b10b42eede543a9c1ae8ea6576eb97ce3cfe6b2e Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:31:56 +0200 Subject: [PATCH] feat(monitor): Expose how coarse a case battery reading is PodDevice.batteryCase merges an AAP notification, the encrypted advertisement payload, the public advertisement nibble and the cache into one Float, and the provenance is gone by the time anyone reads it. The public nibble only carries deciles, so 20% there can mean anything up to 29%. batteryCaseReading walks the same precedence and keeps the step size of the source that won, leaving batteryCase untouched for every existing caller. --- .../capod/monitor/core/BatteryReading.kt | 29 +++++++++++++++++++ .../core/apple/ble/PodDeviceExtensions.kt | 6 ++++ .../core/apple/ble/devices/DualApplePods.kt | 9 ++++++ .../pods/core/apple/ble/devices/HasCase.kt | 11 ++++++- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/BatteryReading.kt diff --git a/app/src/main/java/eu/darken/capod/monitor/core/BatteryReading.kt b/app/src/main/java/eu/darken/capod/monitor/core/BatteryReading.kt new file mode 100644 index 00000000..c2339b89 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/BatteryReading.kt @@ -0,0 +1,29 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE +import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT +import eu.darken.capod.pods.core.apple.ble.devices.HasCase + +/** + * A battery percentage together with the step size of the source it came from. A reading of 20% + * from a decile source means "somewhere in [20%, 30%)", which is the difference between a claim we + * can make and one we can't. + */ +data class BatteryReading( + val percent: Float, + val resolution: Float, +) + +/** + * [PodDevice.batteryCase] resolved through the same precedence, but keeping the resolution of + * whichever source won. Cache entries store no resolution, so they report the coarsest one. + */ +val PodDevice.batteryCaseReading: BatteryReading? + get() { + aap?.batteryCase?.let { return BatteryReading(it, BATTERY_RESOLUTION_PERCENT) } + (ble as? HasCase)?.let { snapshot -> + snapshot.batteryCasePercent?.let { return BatteryReading(it, snapshot.batteryCaseResolution) } + } + cached?.case?.percent?.let { return BatteryReading(it, BATTERY_RESOLUTION_DECILE) } + return null + } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt index 3c45ef37..ea2d9d3e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt @@ -21,6 +21,12 @@ import kotlin.math.roundToInt const val BATTERY_UNKNOWN = -1f +/** Step size of a percent-granularity reading: AAP battery notifications and the encrypted payload. */ +const val BATTERY_RESOLUTION_PERCENT = 0.01f + +/** Step size of the public advertisement's battery nibble, which only carries deciles. */ +const val BATTERY_RESOLUTION_DECILE = 0.10f + fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f fun batteryProgress(percent: Float): Float = diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt index 07fd298c..4a982bfa 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePods.kt @@ -4,6 +4,8 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.isBitSet import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.upperNibble +import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE +import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod @@ -137,6 +139,13 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, } } + /** The encrypted payload carries whole percents; the public nibble only carries deciles. */ + override val batteryCaseResolution: Float + get() = when { + payload.private?.asBatteryState(3) != null -> BATTERY_RESOLUTION_PERCENT + else -> BATTERY_RESOLUTION_DECILE + } + override val isCaseCharging: Boolean get() { payload.private?.asBatteryState(3)?.let { return it.isCharging } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/HasCase.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/HasCase.kt index a196d909..c9695823 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/HasCase.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/HasCase.kt @@ -2,14 +2,23 @@ package eu.darken.capod.pods.core.apple.ble.devices import androidx.annotation.DrawableRes import eu.darken.capod.R +import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE interface HasCase { val batteryCasePercent: Float? + /** + * Step size of [batteryCasePercent]. Deciles unless a decoder knows it read a finer source, so + * a caller reasoning about how much the true value may exceed the reading errs on the coarse + * side. + */ + val batteryCaseResolution: Float + get() = BATTERY_RESOLUTION_DECILE + val isCaseCharging: Boolean @get:DrawableRes val caseIcon: Int get() = R.drawable.device_airpods_gen1_case -} \ No newline at end of file +}