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 41ed9ba2..4f0d2743 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 @@ -264,6 +264,21 @@ enum class PodModel( modelNumbers = setOf("A3184"), // headphones ), + @SerialName("airpods.max2") + AIRPODS_MAX2( + "AirPods Max 2", + R.drawable.device_airpods_max, + Features( + hasEarDetection = true, + hasAncControl = true, + hasPressSpeed = true, + hasPressHoldDuration = true, + hasToneVolume = true, + hasEarDetectionToggle = true, + ), + modelNumbers = setOf("A3454"), // headphones + ), + @SerialName("beats.flex") BEATS_FLEX( "Beats Flex", diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt index 9a8f31eb..9cb8fdba 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt @@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen3 import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4 import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4Anc import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMax +import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMax2 import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMaxUsbc import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2 @@ -53,7 +54,8 @@ abstract class AppleFactoryModule { @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 + @Binds @IntoSet abstract fun airPodsMaxUsbc(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMax2.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt new file mode 100644 index 00000000..ea87c5fd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt @@ -0,0 +1,84 @@ +package eu.darken.capod.pods.core.apple.ble.devices.airpods + +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.isBitSet +import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection +import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods +import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory +import eu.darken.capod.pods.core.apple.ble.devices.HasAppleColor +import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods +import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload +import java.time.Instant +import javax.inject.Inject + +data class AirPodsMax2( + override val identifier: BlePodSnapshot.Id = BlePodSnapshot.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 meta: ApplePods.AppleMeta, + override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, +) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor { + + override val model: PodModel = PodModel.AIRPODS_MAX2 + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + override val isHeadsetBeingCharged: Boolean + get() { + payload.private?.asBatteryState(1)?.let { + return it.isCharging + } + return pubFlags.isBitSet(0) + } + + override val isBeingWorn: Boolean + get() = pubStatus.isBitSet(5) + + 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 suspend fun create( + scanResult: BleScanResult, + payload: ProximityPayload, + meta: ApplePods.AppleMeta + ): ApplePods { + var basic = AirPodsMax2(scanResult = scanResult, payload = payload, meta = meta) + 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, + rssiAverage = result.rssiSmoothed(basic.rssi), + ) + } + } + + companion object { + private val DEVICE_CODE = 0x2D20.toUShort() + private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max2") + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt new file mode 100644 index 00000000..6257a91e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt @@ -0,0 +1,55 @@ +package eu.darken.capod.pods.core.apple.ble.devices.airpods + +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.BaseBlePodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class AirPodsMax2Test : BaseBlePodsTest() { + + @Test + fun `test AirPods Max 2 - right pod in use`() = runTest { + create("07 19 01 2D 20 2B F6 8F 03 14 05 E7 17 3C 7D 65 B8 F3 94 45 12 90 EB E3 24 D3 3E") { + pubPrefix shouldBe 0x01.toUByte() + pubDeviceModel shouldBe 0x2D20.toUShort() + pubStatus shouldBe 0x2B.toUByte() + pubPodsBattery shouldBe 0xF6.toUByte() + pubFlags shouldBe 0x8.toUShort() + pubCaseBattery shouldBe 0xF.toUShort() + pubCaseLidState shouldBe 0x03.toUByte() + pubDeviceColor shouldBe 0x14.toUByte() + pubSuffix shouldBe 0x05.toUByte() + + batteryHeadsetPercent shouldBe 0.6f + + isHeadsetBeingCharged shouldBe false + + model shouldBe PodModel.AIRPODS_MAX2 + } + } + + @Test + fun `test AirPods Max 2 - status variations`() = runTest { + create("07 19 01 2D 20 20 F6 8F 03 14 04 91 DF 89 19 E4 87 FB E9 DA 39 CE 26 BC AD 57 05") { + pubStatus shouldBe 0x20.toUByte() + batteryHeadsetPercent shouldBe 0.6f + isHeadsetBeingCharged shouldBe false + model shouldBe PodModel.AIRPODS_MAX2 + } + + create("07 19 01 2D 20 21 F6 8F 03 14 04 54 5D 3F 3D 3D D9 6E F0 26 B9 0F 93 69 5F FB 46") { + pubStatus shouldBe 0x21.toUByte() + batteryHeadsetPercent shouldBe 0.6f + isHeadsetBeingCharged shouldBe false + model shouldBe PodModel.AIRPODS_MAX2 + } + + create("07 19 01 2D 20 23 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") { + pubStatus shouldBe 0x23.toUByte() + batteryHeadsetPercent shouldBe 0.6f + isHeadsetBeingCharged shouldBe false + model shouldBe PodModel.AIRPODS_MAX2 + } + } +}