From 33126be81749b7d00a5eae1ea328e84c790349a0 Mon Sep 17 00:00:00 2001 From: li-fstz Date: Sat, 20 Sep 2025 20:04:52 +0800 Subject: [PATCH] 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