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,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
val features: Features = Features(),
val modelNumbers: Set<String> = emptySet(),
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
@@ -19,6 +20,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
),
@SerialName("airpods.gen2")
@@ -29,6 +31,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
),
@SerialName("airpods.gen3")
@@ -39,6 +42,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
),
@SerialName("airpods.gen4")
@@ -49,6 +53,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
),
@SerialName("airpods.gen4.anc")
@@ -73,6 +78,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
),
@SerialName("airpods.pro")
@@ -91,6 +97,7 @@ enum class PodModel(
hasVolumeSwipeLength = true,
hasToneVolume = true,
),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
),
@SerialName("airpods.pro2")
@@ -115,6 +122,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
),
@SerialName("airpods.pro2.usbc")
@@ -139,6 +147,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
),
@SerialName("airpods.pro3")
@@ -163,6 +172,7 @@ enum class PodModel(
hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
),
@SerialName("airpods.max")
@@ -175,6 +185,7 @@ enum class PodModel(
hasPressHoldDuration = true,
hasToneVolume = true,
),
modelNumbers = setOf("A2096"), // headphones
),
@SerialName("airpods.max.usbc")
@@ -187,23 +198,37 @@ enum class PodModel(
hasPressHoldDuration = true,
hasToneVolume = true,
),
modelNumbers = setOf("A3184"), // headphones
),
@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")
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")
BEATS_SOLO_PRO(
"Beats Solo Pro",
R.drawable.device_beats_headphones,
Features(hasAncControl = true),
modelNumbers = setOf("A1881"), // headphones
),
@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")
BEATS_SOLO_BUDS(
@@ -213,6 +238,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case
),
@SerialName("beats.studio.3")
@@ -220,6 +246,7 @@ enum class PodModel(
"Beats Studio 3",
R.drawable.device_beats_studio3,
Features(hasAncControl = true),
modelNumbers = setOf("A1914"), // headphones
),
@SerialName("beats.studio.buds")
@@ -232,6 +259,7 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case
),
@SerialName("beats.studio.buds.plus")
@@ -244,6 +272,7 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case
),
@SerialName("beats.studio.pro")
@@ -251,16 +280,29 @@ enum class PodModel(
"Beats Studio Pro",
R.drawable.device_beats_headphones,
Features(hasAncControl = true),
modelNumbers = setOf("A2924"), // headphones
),
@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")
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")
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")
POWERBEATS_PRO(
@@ -271,6 +313,7 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
),
@SerialName("beats.powerbeats.pro2")
@@ -283,6 +326,7 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
),
@SerialName("beats.fit.pro")
@@ -295,6 +339,7 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
),
@SerialName("fakes.tws.i99999")
@@ -355,6 +400,16 @@ enum class PodModel(
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(
// Physical form
val hasDualPods: Boolean = false,
@@ -377,4 +432,4 @@ enum class PodModel(
// Protocol
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.flow.setupCommonEventHandlers
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.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
@@ -28,10 +32,12 @@ class AapAutoConnect @Inject constructor(
private val blePodMonitor: BlePodMonitor,
) {
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
fun monitor(): Flow<Unit> = merge(
initialConnect(),
reconnectOnDisconnect(),
correctModelOnDeviceInfo(),
)
private fun initialConnect(): Flow<Unit> = combine(
@@ -136,6 +142,54 @@ class AapAutoConnect @Inject constructor(
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.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 {
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)
@@ -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.PodModel
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.protocol.AapDeviceInfo
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -66,7 +67,7 @@ class AapAutoConnectTest : BaseTest() {
every { disconnectEvents } returns disconnectEventsFlow
}
profilesRepo = mockk {
profilesRepo = mockk(relaxUnitFun = true) {
every { profiles } returns profilesFlow
}
@@ -366,4 +367,171 @@ class AapAutoConnectTest : BaseTest() {
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()
}
}
}