From 33126be81749b7d00a5eae1ea328e84c790349a0 Mon Sep 17 00:00:00 2001 From: li-fstz Date: Sat, 20 Sep 2025 20:04:52 +0800 Subject: [PATCH 1/3] Add support for AirPods Pro 3 --- .../eu/darken/capod/pods/core/PodDevice.kt | 3 + .../pods/core/apple/AppleFactoryModule.kt | 2 + .../pods/core/apple/airpods/AirPodsPro3.kt | 83 +++++++++++++++++++ .../pods/core/apple/airpods/AirPodsPro3.kt | 28 +++++++ 4 files changed, 116 insertions(+) create mode 100644 app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt create mode 100644 app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index d3532b49..c973f0f4 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -104,6 +104,9 @@ interface PodDevice { "AirPods Pro 2 USB-C", R.drawable.devic_airpods_pro2_both ), + @Json(name = "airpods.pro3") AIRPODS_PRO3( + "AirPods Pro 3" + ), @Json(name = "airpods.max") AIRPODS_MAX( "AirPods Max", R.drawable.devic_headphones_generic diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt index 64af3c58..57c59cb9 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt @@ -15,6 +15,7 @@ import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc import eu.darken.capod.pods.core.apple.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2 import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc +import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3 import eu.darken.capod.pods.core.apple.beats.BeatsFitPro import eu.darken.capod.pods.core.apple.beats.BeatsFlex import eu.darken.capod.pods.core.apple.beats.BeatsSolo3 @@ -43,6 +44,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt new file mode 100644 index 00000000..5b68cf32 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt @@ -0,0 +1,83 @@ +package eu.darken.capod.pods.core.apple.airpods + +import androidx.annotation.DrawableRes +import eu.darken.capod.common.R +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.ApplePods +import eu.darken.capod.pods.core.apple.ApplePodsFactory +import eu.darken.capod.pods.core.apple.DualApplePods +import eu.darken.capod.pods.core.apple.DualApplePods.LidState +import eu.darken.capod.pods.core.apple.history.PodHistoryRepo +import eu.darken.capod.pods.core.apple.protocol.ProximityMessage +import eu.darken.capod.pods.core.apple.protocol.ProximityPairing +import eu.darken.capod.pods.core.apple.protocol.ProximityPayload +import java.time.Instant +import javax.inject.Inject + +data class AirPodsPro3( + override val identifier: PodDevice.Id = PodDevice.Id(), + override val seenLastAt: Instant = Instant.now(), + override val seenFirstAt: Instant = Instant.now(), + override val seenCounter: Int = 1, + override val scanResult: BleScanResult, + override val payload: ProximityPayload, + override val flags: ApplePods.Flags, + override val reliability: Float = PodDevice.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, + private val cachedBatteryPercentage: Float? = null, + private val cachedCaseState: LidState? = null +) : DualApplePods, HasStateDetectionAirPods { + + override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO3 + + override val batteryCasePercent: Float? + get() = super.batteryCasePercent ?: cachedBatteryPercentage + + override val caseLidState: LidState + get() = cachedCaseState ?: super.caseLidState + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + class Factory @Inject constructor( + private val repo: PodHistoryRepo, + ) : ApplePodsFactory { + + override fun isResponsible(message: ProximityMessage): Boolean = message.run { + getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH + } + + override fun create( + scanResult: BleScanResult, + payload: ProximityPayload, + flags: ApplePods.Flags + ): ApplePods { + var basic = AirPodsPro3(scanResult = scanResult, payload = payload, flags = flags) + val result = repo.search(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + repo.updateHistory(basic) + + if (result == null) return basic + + return basic.copy( + identifier = result.id, + seenFirstAt = result.seenFirstAt, + seenLastAt = scanResult.receivedAt, + seenCounter = result.seenCounter, + reliability = result.reliability, + cachedBatteryPercentage = result.getLatestCaseBattery(), + rssiAverage = result.rssiSmoothed(basic.rssi), + cachedCaseState = result.getLatestCaseLidState(basic) + ) + } + + } + + companion object { + private val DEVICE_CODE = 0x2720.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro3") + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt new file mode 100644 index 00000000..482a7f41 --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt @@ -0,0 +1,28 @@ +package eu.darken.capod.pods.core.apple.airpods + +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.BaseAirPodsTest +import eu.darken.capod.pods.core.apple.HasAppleColor +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class AirPodsPro3Test : BaseAirPodsTest() { + + @Test + fun `AirPods Pro 3`() = runTest { + create("07 19 01 27 20 0B 99 8F 11 00 05 43 A8 85 17 07 FF 41 62 2C FE 5E 20 08 1A 52 40") { + isLeftPodInEar shouldBe true + isRightPodInEar shouldBe true + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + + model shouldBe PodDevice.Model.AIRPODS_PRO3 + } + } +} \ No newline at end of file From 247bc13e0abaae7289ba834efa978f200345bd2c Mon Sep 17 00:00:00 2001 From: Tharen Abela <23165475+atharen@users.noreply.github.com> Date: Sun, 21 Sep 2025 03:29:48 +0900 Subject: [PATCH 2/3] Initial support for AirPods Pro 3 This commit introduces the AirPods Pro 3 model to the application, including its representation in the README, the PodDevice interface, and the AppleFactoryModule. A new class for AirPods Pro 3 is created, along with corresponding unit tests to validate its functionality. Based on the AirPods Pro 2 USBC. --- README.md | 1 + .../eu/darken/capod/pods/core/PodDevice.kt | 4 + .../pods/core/apple/AppleFactoryModule.kt | 2 + .../pods/core/apple/airpods/AirPodsPro3.kt | 101 ++++++++++++++++++ .../core/apple/airpods/AirPodsPro3Test.kt | 77 +++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt create mode 100644 app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt diff --git a/README.md b/README.md index 7856bb32..c77f1c5f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Currently supported models: * AirPods Pro 1. Generation * AirPods Pro 2. Generation * AirPods Pro 2. Generation (USB-C) +* AirPods Pro 3. Generation * AirPods Max * Power Beats Pro * Power Beats 3 diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index d3532b49..0cd7faba 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -104,6 +104,10 @@ interface PodDevice { "AirPods Pro 2 USB-C", R.drawable.devic_airpods_pro2_both ), + @Json(name = "airpods.pro3") AIRPODS_PRO3( + "AirPods Pro 3", + R.drawable.devic_airpods_pro2_both + ), @Json(name = "airpods.max") AIRPODS_MAX( "AirPods Max", R.drawable.devic_headphones_generic diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt index 64af3c58..57c59cb9 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt @@ -15,6 +15,7 @@ import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc import eu.darken.capod.pods.core.apple.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2 import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc +import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3 import eu.darken.capod.pods.core.apple.beats.BeatsFitPro import eu.darken.capod.pods.core.apple.beats.BeatsFlex import eu.darken.capod.pods.core.apple.beats.BeatsSolo3 @@ -43,6 +44,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt new file mode 100644 index 00000000..d3a4e01d --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt @@ -0,0 +1,101 @@ +package eu.darken.capod.pods.core.apple.airpods + +import androidx.annotation.DrawableRes +import eu.darken.capod.common.R +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.ApplePods +import eu.darken.capod.pods.core.apple.ApplePodsFactory +import eu.darken.capod.pods.core.apple.DualApplePods +import eu.darken.capod.pods.core.apple.DualApplePods.LidState +import eu.darken.capod.pods.core.apple.history.PodHistoryRepo +import eu.darken.capod.pods.core.apple.protocol.ProximityMessage +import eu.darken.capod.pods.core.apple.protocol.ProximityPairing +import eu.darken.capod.pods.core.apple.protocol.ProximityPayload +import java.time.Instant +import javax.inject.Inject + +data class AirPodsPro3( + override val identifier: PodDevice.Id = PodDevice.Id(), + override val seenLastAt: Instant = Instant.now(), + override val seenFirstAt: Instant = Instant.now(), + override val seenCounter: Int = 1, + override val scanResult: BleScanResult, + override val payload: ProximityPayload, + override val flags: ApplePods.Flags, + override val reliability: Float = PodDevice.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, + private val cachedBatteryPercentage: Float? = null, + private val cachedCaseState: LidState? = null +) : DualApplePods, HasStateDetectionAirPods { + + override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO3 + + @get:DrawableRes + override val iconRes: Int + get() = R.drawable.devic_airpods_pro2_both + + @get:DrawableRes + override val caseIcon: Int + get() = R.drawable.devic_airpods_pro2_case + + @get:DrawableRes + override val leftPodIcon: Int + get() = R.drawable.devic_airpods_pro2_left + + @get:DrawableRes + override val rightPodIcon: Int + get() = R.drawable.devic_airpods_pro2_right + + override val batteryCasePercent: Float? + get() = super.batteryCasePercent ?: cachedBatteryPercentage + + override val caseLidState: LidState + get() = cachedCaseState ?: super.caseLidState + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + class Factory @Inject constructor( + private val repo: PodHistoryRepo, + ) : ApplePodsFactory { + + override fun isResponsible(message: ProximityMessage): Boolean = message.run { + getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH + } + + override fun create( + scanResult: BleScanResult, + payload: ProximityPayload, + flags: ApplePods.Flags + ): ApplePods { + var basic = AirPodsPro3(scanResult = scanResult, payload = payload, flags = flags) + val result = repo.search(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + repo.updateHistory(basic) + + if (result == null) return basic + + return basic.copy( + identifier = result.id, + seenFirstAt = result.seenFirstAt, + seenLastAt = scanResult.receivedAt, + seenCounter = result.seenCounter, + reliability = result.reliability, + cachedBatteryPercentage = result.getLatestCaseBattery(), + rssiAverage = result.rssiSmoothed(basic.rssi), + cachedCaseState = result.getLatestCaseLidState(basic) + ) + } + + } + + companion object { + // Device code for AirPods Pro 3 + // Following the pattern of other AirPods models (ends with 0x20 for Apple vendor) + private val DEVICE_CODE = 0x2720.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro3") + } +} diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt new file mode 100644 index 00000000..90129ddc --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt @@ -0,0 +1,77 @@ +package eu.darken.capod.pods.core.apple.airpods + +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.BaseAirPodsTest +import eu.darken.capod.pods.core.apple.HasAppleColor +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class AirPodsPro3Test : BaseAirPodsTest() { + + /** + * Test case for AirPods Pro 3 - placeholder with correct device code + * Following the pattern of other AirPods models (0x2720) + */ + @Test + fun `AirPods Pro 3 - placeholder test`() = runTest { + // This test uses a placeholder hex string with the correct device code 0x2720 + // The actual proximity pairing data will need to be captured from real AirPods Pro 3 + create("07 19 01 27 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") { + + pubPrefix shouldBe 0x01.toUByte() + pubDeviceModel shouldBe 0x2720.toUShort() + pubStatus shouldBe 0x0B.toUByte() + pubPodsBattery shouldBe 0x99.toUByte() + pubFlags shouldBe 0x8.toUShort() + pubCaseBattery shouldBe 0xF.toUShort() + pubCaseLidState shouldBe 0x11.toUByte() + pubDeviceColor shouldBe 0x0.toUByte() + pubSuffix shouldBe 0x04.toUByte() + + isLeftPodMicrophone shouldBe false + isRightPodMicrophone shouldBe true + + isLeftPodInEar shouldBe true + isRightPodInEar shouldBe true + + batteryLeftPodPercent shouldBe 0.9f + batteryRightPodPercent shouldBe 0.9f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + + model shouldBe PodDevice.Model.AIRPODS_PRO3 + } + } + + @Test + fun `AirPods Pro 3 - placeholder test - in case`() = runTest { + // This test uses a placeholder hex string with the correct device code 0x2720 + // The actual proximity pairing data will need to be captured from real AirPods Pro 3 + create("07 19 01 27 20 53 AA 98 32 00 05 49 0A B8 BF 8E 29 D8 70 12 0D A7 0C CE 77 56 00") { + + isLeftPodMicrophone shouldBe true + isRightPodMicrophone shouldBe false + + isLeftPodInEar shouldBe true + isRightPodInEar shouldBe false + + batteryLeftPodPercent shouldBe 1f + batteryRightPodPercent shouldBe 1f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe 0.8f + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + + model shouldBe PodDevice.Model.AIRPODS_PRO3 + } + } +} From 15054d836d8468095729df17d557ef90e7a61b8e Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 21 Sep 2025 18:38:13 +0200 Subject: [PATCH 3/3] Merge branch 'airpods-pro-3' into airpods-pro-3-merged # Conflicts: # app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt # app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt --- .../pods/core/apple/airpods/AirPodsPro3.kt | 28 ------------------- .../core/apple/airpods/AirPodsPro3Test.kt | 17 +++++++++++ 2 files changed, 17 insertions(+), 28 deletions(-) delete mode 100644 app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt deleted file mode 100644 index 482a7f41..00000000 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.darken.capod.pods.core.apple.airpods - -import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.apple.BaseAirPodsTest -import eu.darken.capod.pods.core.apple.HasAppleColor -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test - -class AirPodsPro3Test : BaseAirPodsTest() { - - @Test - fun `AirPods Pro 3`() = runTest { - create("07 19 01 27 20 0B 99 8F 11 00 05 43 A8 85 17 07 FF 41 62 2C FE 5E 20 08 1A 52 40") { - isLeftPodInEar shouldBe true - isRightPodInEar shouldBe true - - isCaseCharging shouldBe false - isRightPodCharging shouldBe false - isLeftPodCharging shouldBe false - batteryCasePercent shouldBe null - - podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name - - model shouldBe PodDevice.Model.AIRPODS_PRO3 - } - } -} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt index 90129ddc..2bd4eaeb 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt @@ -74,4 +74,21 @@ class AirPodsPro3Test : BaseAirPodsTest() { model shouldBe PodDevice.Model.AIRPODS_PRO3 } } + + @Test + fun `AirPods Pro 3`() = runTest { + create("07 19 01 27 20 0B 99 8F 11 00 05 43 A8 85 17 07 FF 41 62 2C FE 5E 20 08 1A 52 40") { + isLeftPodInEar shouldBe true + isRightPodInEar shouldBe true + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + + model shouldBe PodDevice.Model.AIRPODS_PRO3 + } + } }