From 5801b8ea56fb41b5a90abc1a71ed4c35ce21f446 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:31:18 +0200 Subject: [PATCH] feat(device): Record how many earbud charges a case holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../darken/capod/pods/core/apple/PodModel.kt | 30 ++++++++++++ .../pods/core/apple/PodModelCaseSpecTest.kt | 49 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/PodModelCaseSpecTest.kt 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 c2116316..cf6d13d9 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 @@ -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, + ) } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/PodModelCaseSpecTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/PodModelCaseSpecTest.kt new file mode 100644 index 00000000..9539e79f --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/PodModelCaseSpecTest.kt @@ -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) + } +}