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.
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent 5801b8ea56
commit b10b42eede
4 changed files with 54 additions and 1 deletions
@@ -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
}
@@ -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 =
@@ -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 }
@@ -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
}
}