feat(device): Record how many earbud charges a case holds

Derived per model as hoursWithCase / hoursListening - 1, with both figures read
under the same noise control condition Apple quotes them under, matching their
stated test method (drained AirPods recharged to 100% and playback resumed
until both the AirPods and the case were fully discharged).

Models whose published total reads "more than N hours" are marked as a lower
bound so nothing downstream can turn an unbounded figure into a confident
claim. AirPods Pro is deliberately left without a spec: its 4.5 h ANC listening
figure is verified but the matching with-case total is not, and an unsourced
number is worse than no line at all.

The spec gates on itself rather than on hasCase — twenty models set hasCase,
including Beats Solo Buds, whose case holds no battery.
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent b9461d3259
commit 5801b8ea56
2 changed files with 79 additions and 0 deletions
@@ -15,7 +15,9 @@ enum class PodModel(
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
val batterySpec: BatterySpec? = null,
val caseSpec: CaseSpec? = null,
) {
/** Case charges: 5 h listening, more than 24 h with the case, noise control off. */
@SerialName("airpods.gen1")
AIRPODS_GEN1(
"AirPods (Gen 1)",
@@ -28,12 +30,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
/** Case charges: 5 h listening, more than 24 h with the case, noise control off (SP791). */
@SerialName("airpods.gen2")
AIRPODS_GEN2(
"AirPods (Gen 2)",
@@ -46,12 +50,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
/** Case charges: 6 h listening, 30 h with the case, noise control off. */
@SerialName("airpods.gen3")
AIRPODS_GEN3(
"AirPods (Gen 3)",
@@ -68,12 +74,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
/** Case charges: 5 h listening, 30 h with the case, noise control off. */
@SerialName("airpods.gen4")
AIRPODS_GEN4(
"AirPods (Gen 4)",
@@ -91,12 +99,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 5.0f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
/** Case charges: 4 h listening, 20 h with the case, ANC on. */
@SerialName("airpods.gen4.anc")
AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
@@ -123,6 +133,7 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -155,6 +166,7 @@ enum class PodModel(
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 6 h listening, 30 h with the case, ANC on. */
@SerialName("airpods.pro2")
AIRPODS_PRO2(
"AirPods Pro 2",
@@ -183,12 +195,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 6 h listening, 30 h with the case, ANC on. */
@SerialName("airpods.pro2.usbc")
AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
@@ -217,12 +231,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 8 h listening, 24 h with the case, ANC on (support.apple.com/en-us/125135). */
@SerialName("airpods.pro3")
AIRPODS_PRO3(
"AirPods Pro 3",
@@ -252,6 +268,7 @@ enum class PodModel(
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
caseSpec = CaseSpec(fullPairRecharges = 2.0f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -616,4 +633,17 @@ enum class PodModel(
*/
val chargeFractionPerHour: Float? = null,
)
/**
* How many times a full case recharges BOTH earbuds, derived from Apple's published figures as
* `hoursWithCase / hoursListening - 1`. Both figures have to be read under the same noise
* control condition, which is why every entry records the pair it came from. Only a case that
* holds a battery carries a spec: [Features.hasCase] describes the physical case, and models
* such as Beats Solo Buds ship one that doesn't charge.
*/
data class CaseSpec(
val fullPairRecharges: Float,
/** True when the published total was "more than N hours" - the value is a lower bound. */
val isLowerBound: Boolean = false,
)
}
@@ -0,0 +1,49 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.floats.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodModelCaseSpecTest : BaseTest() {
@Test
fun `placeholder models carry no case spec`() {
PodModel.entries
.filter { it == PodModel.UNKNOWN || it.name.startsWith("FAKE_") }
.filter { it.caseSpec != null }
.shouldBeEmpty()
}
@Test
fun `a case spec implies a case`() {
PodModel.entries
.filter { it.caseSpec != null }
.filter { !it.features.hasCase }
.shouldBeEmpty()
}
@Test
fun `every case spec is usable`() {
PodModel.entries.mapNotNull { it.caseSpec }.forEach {
it.fullPairRecharges.isFinite() shouldBe true
it.fullPairRecharges shouldBeGreaterThan 0f
}
}
@Test
fun `the published figures divide out as recorded`() {
// hoursWithCase / hoursListening - 1
PodModel.AIRPODS_GEN1.caseSpec?.fullPairRecharges shouldBe 3.8f
PodModel.AIRPODS_GEN1.caseSpec?.isLowerBound shouldBe true
PodModel.AIRPODS_GEN2.caseSpec?.isLowerBound shouldBe true
PodModel.AIRPODS_GEN3.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_GEN3.caseSpec?.isLowerBound shouldBe false
PodModel.AIRPODS_GEN4.caseSpec?.fullPairRecharges shouldBe (30f / 5f - 1f)
PodModel.AIRPODS_GEN4_ANC.caseSpec?.fullPairRecharges shouldBe (20f / 4f - 1f)
PodModel.AIRPODS_PRO2.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_PRO2_USBC.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_PRO3.caseSpec?.fullPairRecharges shouldBe (24f / 8f - 1f)
}
}