diff --git a/README.md b/README.md index 32f45f26..81994c3a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ Currently supported models: * AirPods 1. Generation * AirPods 2. Generation * AirPods 3. Generation -* AirPods Pro +* AirPods Pro 1. Generation +* AirPods Pro 2. 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 0f48d3f6..8a825916 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 @@ -78,6 +78,10 @@ interface PodDevice { "AirPods Pro", R.drawable.ic_device_airpods_gen2 ), + @Json(name = "airpods.pro2") AIRPODS_PRO2( + "AirPods Pro 2", + R.drawable.ic_device_airpods_gen2 + ), @Json(name = "airpods.max") AIRPODS_MAX( "AirPods Max", R.drawable.ic_device_generic_headphones 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 30d38373..8a1e1289 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 @@ -19,6 +19,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory @Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/ApplePodsFactory.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/ApplePodsFactory.kt index 0a861a2a..3fcbfbf4 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/ApplePodsFactory.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/ApplePodsFactory.kt @@ -83,7 +83,7 @@ abstract class ApplePodsFactory(private val tag: String) { if (definitive.contains(basic.caseLidState)) return null return history - .filterIsInstance() + .filterIsInstance() // TODO why is this AirPodsPro specific here? .lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE } ?.caseLidState } diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro.kt index 9a42b8fd..8e5706dc 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro.kt @@ -62,9 +62,10 @@ data class AirPodsPro( ) } - companion object { - private val DEVICE_CODE = 0x0e20.toUShort() - private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory") - } + } + + companion object { + private val DEVICE_CODE = 0x0e20.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro") } } \ No newline at end of file diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2.kt new file mode 100644 index 00000000..afa88e57 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2.kt @@ -0,0 +1,71 @@ +package eu.darken.capod.pods.core.apple.airpods + +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.DualAirPods +import eu.darken.capod.pods.core.apple.DualAirPods.LidState +import eu.darken.capod.pods.core.apple.DualApplePodsFactory +import eu.darken.capod.pods.core.apple.protocol.ProximityPairing +import java.time.Instant +import javax.inject.Inject + +data class AirPodsPro2( + 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 proximityMessage: ProximityPairing.Message, + override val confidence: Float = PodDevice.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, + private val cachedBatteryPercentage: Float? = null, + private val cachedCaseState: LidState? = null +) : DualAirPods { + + override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2 + + 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() : DualApplePodsFactory(TAG) { + + override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run { + getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH + } + + override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods { + var basic = AirPodsPro2(scanResult = scanResult, proximityMessage = message) + val result = searchHistory(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + updateHistory(basic) + + if (result == null) return basic + + return basic.copy( + identifier = result.id, + seenFirstAt = result.seenFirstAt, + seenLastAt = scanResult.receivedAt, + seenCounter = result.seenCounter, + confidence = result.confidence, + cachedBatteryPercentage = result.getLatestCaseBattery(), + rssiAverage = result.averageRssi(basic.rssi), + cachedCaseState = result.getLatestCaseLidState(basic) + ) + } + + } + + companion object { + private val DEVICE_CODE = 0x1420.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2") + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt new file mode 100644 index 00000000..d3c3d972 --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt @@ -0,0 +1,80 @@ +package eu.darken.capod.pods.core.apple.airpods + +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.runBlockingTest +import org.junit.jupiter.api.Test + +class AirPodsPro2Test : BaseAirPodsTest() { + + /** + * https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084 + */ + @Test + fun `test AirPods Pro 2 - unknown setup from #31`() = runBlockingTest { + create("07 19 01 14 20 55 88 F9 51 00 04 20 50 03 CA D5 C9 AC 0F FA 84 78 94 5A 4D DF F5") { + + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x1420.toUShort() + rawStatus shouldBe 0x55.toUByte() + rawPodsBattery shouldBe 0x88.toUByte() + rawFlags shouldBe 0xF.toUShort() + rawCaseBattery shouldBe 0x9.toUShort() + rawCaseLidState shouldBe 0x51.toUByte() + rawDeviceColor shouldBe 0x0.toUByte() + rawSuffix shouldBe 0x04.toUByte() + + isLeftPodMicrophone shouldBe true + isRightPodMicrophone shouldBe false + + isLeftPodInEar shouldBe false + isRightPodInEar shouldBe false + + batteryLeftPodPercent shouldBe 0.8f + batteryRightPodPercent shouldBe 0.8f + + isCaseCharging shouldBe true + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe true + batteryCasePercent shouldBe 0.9f + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + } + } + + /** + * https://old.reddit.com/message/messages/1hst12h + */ + @Test + fun `test AirPods Pro 2 - unknown setup from reddit user`() = runBlockingTest { + create("07 19 01 14 20 2B 9A 8F 01 00 04 0F 26 1A C4 2B FA 2F B9 B6 08 CD 60 CB DF 75 AB") { + + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x1420.toUShort() + rawStatus shouldBe 0x2B.toUByte() + rawPodsBattery shouldBe 0x9A.toUByte() + rawFlags shouldBe 0x8.toUShort() + rawCaseBattery shouldBe 0xF.toUShort() + rawCaseLidState shouldBe 0x01.toUByte() + rawDeviceColor shouldBe 0x0.toUByte() + rawSuffix shouldBe 0x04.toUByte() + + isLeftPodMicrophone shouldBe true + isRightPodMicrophone shouldBe false + + isLeftPodInEar shouldBe true + isRightPodInEar shouldBe true + + batteryLeftPodPercent shouldBe 1.0f + batteryRightPodPercent shouldBe 0.9f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name + } + } +} \ No newline at end of file