mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user