feat: Auto-correct device model from AAP device info

When connected via AAP, the device reports its hardware model number. If the profile has a wrong or missing model, detect the mismatch and automatically correct it with a reconnect to apply correct feature flags (ANC modes, InitExt, etc.).

Adds modelNumbers field to PodModel enum with Apple hardware identifiers for all known devices, and a fromModelNumber() lookup function.
This commit is contained in:
darken
2026-03-31 20:52:57 +02:00
committed by Matthias Urhahn
parent 04ea3d0cd8
commit 7bdc9ca212
3 changed files with 286 additions and 9 deletions
@@ -10,6 +10,7 @@ enum class PodModel(
val label: String, val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both, @DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
val features: Features = Features(), val features: Features = Features(),
val modelNumbers: Set<String> = emptySet(),
) { ) {
@SerialName("airpods.gen1") @SerialName("airpods.gen1")
AIRPODS_GEN1( AIRPODS_GEN1(
@@ -19,6 +20,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
), ),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
), ),
@SerialName("airpods.gen2") @SerialName("airpods.gen2")
@@ -29,6 +31,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
), ),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
), ),
@SerialName("airpods.gen3") @SerialName("airpods.gen3")
@@ -39,6 +42,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
), ),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
), ),
@SerialName("airpods.gen4") @SerialName("airpods.gen4")
@@ -49,6 +53,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
), ),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
), ),
@SerialName("airpods.gen4.anc") @SerialName("airpods.gen4.anc")
@@ -73,6 +78,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true, hasAdaptiveAudioNoise = true,
needsInitExt = true, needsInitExt = true,
), ),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
), ),
@SerialName("airpods.pro") @SerialName("airpods.pro")
@@ -91,6 +97,7 @@ enum class PodModel(
hasVolumeSwipeLength = true, hasVolumeSwipeLength = true,
hasToneVolume = true, hasToneVolume = true,
), ),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
), ),
@SerialName("airpods.pro2") @SerialName("airpods.pro2")
@@ -115,6 +122,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true, hasAdaptiveAudioNoise = true,
needsInitExt = true, needsInitExt = true,
), ),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
), ),
@SerialName("airpods.pro2.usbc") @SerialName("airpods.pro2.usbc")
@@ -139,6 +147,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true, hasAdaptiveAudioNoise = true,
needsInitExt = true, needsInitExt = true,
), ),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
), ),
@SerialName("airpods.pro3") @SerialName("airpods.pro3")
@@ -163,6 +172,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true, hasAdaptiveAudioNoise = true,
needsInitExt = true, needsInitExt = true,
), ),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
), ),
@SerialName("airpods.max") @SerialName("airpods.max")
@@ -175,6 +185,7 @@ enum class PodModel(
hasPressHoldDuration = true, hasPressHoldDuration = true,
hasToneVolume = true, hasToneVolume = true,
), ),
modelNumbers = setOf("A2096"), // headphones
), ),
@SerialName("airpods.max.usbc") @SerialName("airpods.max.usbc")
@@ -187,23 +198,37 @@ enum class PodModel(
hasPressHoldDuration = true, hasPressHoldDuration = true,
hasToneVolume = true, hasToneVolume = true,
), ),
modelNumbers = setOf("A3184"), // headphones
), ),
@SerialName("beats.flex") @SerialName("beats.flex")
BEATS_FLEX("Beats Flex", R.drawable.device_beats_earbuds), BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
modelNumbers = setOf("A2295"),
),
@SerialName("beats.solo.3") @SerialName("beats.solo.3")
BEATS_SOLO_3("Beats Solo 3", R.drawable.device_beats_headphones), BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
modelNumbers = setOf("A1796"), // headphones
),
@SerialName("beats.solo.pro") @SerialName("beats.solo.pro")
BEATS_SOLO_PRO( BEATS_SOLO_PRO(
"Beats Solo Pro", "Beats Solo Pro",
R.drawable.device_beats_headphones, R.drawable.device_beats_headphones,
Features(hasAncControl = true), Features(hasAncControl = true),
modelNumbers = setOf("A1881"), // headphones
), ),
@SerialName("beats.solo.4") @SerialName("beats.solo.4")
BEATS_SOLO_4("Beats Solo 4", R.drawable.device_beats_headphones), BEATS_SOLO_4(
"Beats Solo 4",
R.drawable.device_beats_headphones,
modelNumbers = setOf("A3140"), // headphones
),
@SerialName("beats.solo.buds") @SerialName("beats.solo.buds")
BEATS_SOLO_BUDS( BEATS_SOLO_BUDS(
@@ -213,6 +238,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
), ),
modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case
), ),
@SerialName("beats.studio.3") @SerialName("beats.studio.3")
@@ -220,6 +246,7 @@ enum class PodModel(
"Beats Studio 3", "Beats Studio 3",
R.drawable.device_beats_studio3, R.drawable.device_beats_studio3,
Features(hasAncControl = true), Features(hasAncControl = true),
modelNumbers = setOf("A1914"), // headphones
), ),
@SerialName("beats.studio.buds") @SerialName("beats.studio.buds")
@@ -232,6 +259,7 @@ enum class PodModel(
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case
), ),
@SerialName("beats.studio.buds.plus") @SerialName("beats.studio.buds.plus")
@@ -244,6 +272,7 @@ enum class PodModel(
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case
), ),
@SerialName("beats.studio.pro") @SerialName("beats.studio.pro")
@@ -251,16 +280,29 @@ enum class PodModel(
"Beats Studio Pro", "Beats Studio Pro",
R.drawable.device_beats_headphones, R.drawable.device_beats_headphones,
Features(hasAncControl = true), Features(hasAncControl = true),
modelNumbers = setOf("A2924"), // headphones
), ),
@SerialName("beats.x") @SerialName("beats.x")
BEATS_X("Beats X", R.drawable.device_beats_x), BEATS_X(
"Beats X",
R.drawable.device_beats_x,
modelNumbers = setOf("A1763"),
),
@SerialName("beats.powerbeats.3") @SerialName("beats.powerbeats.3")
POWERBEATS_3("Power Beats 3", R.drawable.device_powerbeats_3), POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
modelNumbers = setOf("A1747"),
),
@SerialName("beats.powerbeats.4") @SerialName("beats.powerbeats.4")
POWERBEATS_4("Power Beats 4", R.drawable.device_powerbeats_4), POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
modelNumbers = setOf("A2015"),
),
@SerialName("beats.powerbeats.pro") @SerialName("beats.powerbeats.pro")
POWERBEATS_PRO( POWERBEATS_PRO(
@@ -271,6 +313,7 @@ enum class PodModel(
hasCase = true, hasCase = true,
hasEarDetection = true, hasEarDetection = true,
), ),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
), ),
@SerialName("beats.powerbeats.pro2") @SerialName("beats.powerbeats.pro2")
@@ -283,6 +326,7 @@ enum class PodModel(
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
), ),
@SerialName("beats.fit.pro") @SerialName("beats.fit.pro")
@@ -295,6 +339,7 @@ enum class PodModel(
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
), ),
@SerialName("fakes.tws.i99999") @SerialName("fakes.tws.i99999")
@@ -355,6 +400,16 @@ enum class PodModel(
UNKNOWN("Unknown"), UNKNOWN("Unknown"),
; ;
companion object {
fun fromModelNumber(modelNumber: String): PodModel? {
val normalized = modelNumber.trim().uppercase()
if (normalized.isBlank()) return null
return entries.firstOrNull { entry ->
entry != UNKNOWN && !entry.name.startsWith("FAKE_") && normalized in entry.modelNumbers
}
}
}
data class Features( data class Features(
// Physical form // Physical form
val hasDualPods: Boolean = false, val hasDualPods: Boolean = false,
@@ -377,4 +432,4 @@ enum class PodModel(
// Protocol // Protocol
val needsInitExt: Boolean = false, val needsInitExt: Boolean = false,
) )
} }
@@ -7,13 +7,17 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.BlePodMonitor import eu.darken.capod.monitor.core.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -28,10 +32,12 @@ class AapAutoConnect @Inject constructor(
private val blePodMonitor: BlePodMonitor, private val blePodMonitor: BlePodMonitor,
) { ) {
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>()) private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
fun monitor(): Flow<Unit> = merge( fun monitor(): Flow<Unit> = merge(
initialConnect(), initialConnect(),
reconnectOnDisconnect(), reconnectOnDisconnect(),
correctModelOnDeviceInfo(),
) )
private fun initialConnect(): Flow<Unit> = combine( private fun initialConnect(): Flow<Unit> = combine(
@@ -136,6 +142,54 @@ class AapAutoConnect @Inject constructor(
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit> .map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.setupCommonEventHandlers(TAG) { "reconnect" } .setupCommonEventHandlers(TAG) { "reconnect" }
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
.distinctUntilChanged()
.map { modelNumbers ->
// Clean up tracking for addresses that disconnected
processedModelCorrections.retainAll(modelNumbers.keys)
for ((address, modelNumber) in modelNumbers) {
if (modelNumber == null) continue
if (address in processedModelCorrections) continue
processedModelCorrections.add(address)
try {
val detectedModel = PodModel.fromModelNumber(modelNumber) ?: continue
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
?: continue
if (profile.model == detectedModel) {
log(TAG, VERBOSE) { "AAP model confirmed for $address: $detectedModel" }
continue
}
log(TAG) { "AAP model mismatch for $address: profile=${profile.model}, detected=$detectedModel" }
// Allow key exchange (AapKeyPersister) to complete before disconnecting
delay(2.seconds)
aapManager.disconnect(address)
profilesRepo.updateProfile(profile.copy(model = detectedModel))
log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" }
// Reconnect explicitly — initialConnect may be blocked by retry loops for other devices
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
}
} catch (e: Exception) {
log(TAG, WARN) { "AAP model correction failed for $address: ${e.message}" }
}
}
}
.setupCommonEventHandlers(TAG) { "correctModel" }
companion object { companion object {
private val TAG = logTag("Reaction", "AapAutoConnect") private val TAG = logTag("Reaction", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000) internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
@@ -6,11 +6,12 @@ import eu.darken.capod.monitor.core.BlePodMonitor
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@@ -66,7 +67,7 @@ class AapAutoConnectTest : BaseTest() {
every { disconnectEvents } returns disconnectEventsFlow every { disconnectEvents } returns disconnectEventsFlow
} }
profilesRepo = mockk { profilesRepo = mockk(relaxUnitFun = true) {
every { profiles } returns profilesFlow every { profiles } returns profilesFlow
} }
@@ -366,4 +367,171 @@ class AapAutoConnectTest : BaseTest() {
job.cancel() job.cancel()
} }
} }
@Nested
inner class ModelCorrection {
private val testDeviceInfo = AapDeviceInfo(
name = "AirPods Pro 3",
modelNumber = "A3064",
manufacturer = "Apple Inc.",
serialNumber = "XXXX",
firmwareVersion = "1.0",
)
@Test
fun `corrects model when deviceInfo reports different model`() = runTest(testDispatcher) {
val wrongModelProfile = AppleDeviceProfile(
label = "Test AirPods",
model = PodModel.UNKNOWN,
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
var capturedProfile: AppleDeviceProfile? = null
coEvery { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) } coAnswers {
capturedProfile = firstArg()
}
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
// Simulate AAP connection with device info reporting AirPods Pro 3
allStatesFlow.value = mapOf(
testAddress to AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo,
)
)
advanceUntilIdle()
capturedProfile!!.model shouldBe PodModel.AIRPODS_PRO3
coVerify(exactly = 1) { aapManager.disconnect(testAddress) }
coVerify { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@Test
fun `does not correct when model already matches`() = runTest(testDispatcher) {
// Profile already has correct model
profilesFlow.value = listOf(testProfile) // PodModel.AIRPODS_PRO3
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
allStatesFlow.value = mapOf(
testAddress to AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo, // A3064 = AIRPODS_PRO3
)
)
advanceUntilIdle()
coVerify(exactly = 0) { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) }
coVerify(exactly = 0) { aapManager.disconnect(testAddress) }
job.cancel()
}
@Test
fun `does not correct when modelNumber is unrecognized`() = runTest(testDispatcher) {
profilesFlow.value = listOf(testProfile)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
allStatesFlow.value = mapOf(
testAddress to AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo.copy(modelNumber = "ZZZZ"),
)
)
advanceUntilIdle()
coVerify(exactly = 0) { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) }
job.cancel()
}
@Test
fun `correction not re-triggered on subsequent state emissions`() = runTest(testDispatcher) {
val wrongModelProfile = AppleDeviceProfile(
label = "Test AirPods",
model = PodModel.UNKNOWN,
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
val readyState = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo,
)
// First emission with deviceInfo
allStatesFlow.value = mapOf(testAddress to readyState)
advanceUntilIdle()
// Second emission — same modelNumber but different state (simulates battery/settings churn)
// StateFlow needs a structurally different value to emit; distinctUntilChanged on
// the modelNumber sub-map then suppresses re-processing
allStatesFlow.value = mapOf(testAddress to readyState.copy(lastMessageAt = java.time.Instant.now()))
advanceUntilIdle()
coVerify(exactly = 1) { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) }
job.cancel()
}
@Test
fun `clears processed state on disconnect for future reconnects`() = runTest(testDispatcher) {
val wrongModelProfile = AppleDeviceProfile(
label = "Test AirPods",
model = PodModel.UNKNOWN,
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
// First connection with deviceInfo
allStatesFlow.value = mapOf(
testAddress to AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo,
)
)
advanceUntilIdle()
coVerify(exactly = 1) { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) }
// Simulate disconnect (address disappears)
allStatesFlow.value = emptyMap()
advanceUntilIdle()
// Second connection — profile is now corrected, so no second updateProfile
profilesFlow.value = listOf(wrongModelProfile.copy(model = PodModel.AIRPODS_PRO3))
allStatesFlow.value = mapOf(
testAddress to AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = testDeviceInfo,
)
)
advanceUntilIdle()
// Still only 1 updateProfile — model now matches
coVerify(exactly = 1) { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) }
job.cancel()
}
}
} }