From 1443fd5714126696ff4c69151d2d0aa26b29814b Mon Sep 17 00:00:00 2001 From: darken Date: Sat, 23 Sep 2023 21:26:09 +0200 Subject: [PATCH] Support for Apple AirPods Pro2 with USB-C See #164 --- .../eu/darken/capod/pods/core/PodDevice.kt | 6 +- .../pods/core/apple/AppleFactoryModule.kt | 1 + .../core/apple/airpods/AirPodsPro2Usbc.kt | 89 +++++++++++++++++++ .../core/apple/airpods/AirPodsPro2UsbcTest.kt | 48 ++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Usbc.kt create mode 100644 app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.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 340e51f9..6b917322 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 @@ -10,7 +10,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import java.time.Duration import java.time.Instant -import java.util.* +import java.util.UUID import kotlin.math.abs import kotlin.math.max @@ -91,6 +91,10 @@ interface PodDevice { "AirPods Pro 2", R.drawable.devic_airpods_pro2_both ), + @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC( + "AirPods Pro 2 USB-C", + 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 ab5ea20f..d2d7a14d 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 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 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/airpods/AirPodsPro2Usbc.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Usbc.kt new file mode 100644 index 00000000..5ee90850 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Usbc.kt @@ -0,0 +1,89 @@ +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.DualApplePods +import eu.darken.capod.pods.core.apple.DualApplePods.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 AirPodsPro2Usbc( + 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 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_PRO2_USBC + + @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() : 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 = AirPodsPro2Usbc(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, + reliability = result.reliability, + cachedBatteryPercentage = result.getLatestCaseBattery(), + rssiAverage = result.rssiSmoothed(basic.rssi), + cachedCaseState = result.getLatestCaseLidState(basic) + ) + } + + } + + companion object { + private val DEVICE_CODE = 0x2420.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2", "USBC") + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt new file mode 100644 index 00000000..4a3317dc --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt @@ -0,0 +1,48 @@ +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 AirPodsPro2UsbcTest : BaseAirPodsTest() { + + /** + * https://github.com/d4rken-org/capod/issues/164 + */ + @Test + fun `AirPods Pro 2 with USB-C - via #164`() = runTest { + create("07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") { + + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x2420.toUShort() + rawStatus shouldBe 0x0B.toUByte() + rawPodsBattery shouldBe 0x99.toUByte() + rawFlags shouldBe 0x8.toUShort() + rawCaseBattery shouldBe 0xF.toUShort() + rawCaseLidState shouldBe 0x11.toUByte() + rawDeviceColor shouldBe 0x0.toUByte() + rawSuffix 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_PRO2_USBC + } + } +} \ No newline at end of file