support for airpods max 2 added

This commit is contained in:
RomaZinkevich
2026-04-10 07:58:46 +02:00
committed by Matthias Urhahn
parent a650405c2d
commit aeaf92d7e6
4 changed files with 157 additions and 1 deletions
@@ -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",
@@ -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
@@ -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<SingleApplePods>.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")
}
}
@@ -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<AirPodsMax2>("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<AirPodsMax2>("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<AirPodsMax2>("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<AirPodsMax2>("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
}
}
}