Add support for PowerBeats Pro 2

This commit introduces support for the PowerBeats Pro 2 device.

Had to change the PowerBeats Pro 1 device code due to conflicting matches. Previously was using the half code variant. Might have broken that, but don't have any feedback from actual users, someone will have to speak up...

Closes #298
This commit is contained in:
darken
2025-06-21 14:01:16 +02:00
committed by Matthias Urhahn
parent 1278ec55c2
commit 738a35dbe9
5 changed files with 148 additions and 2 deletions
@@ -133,6 +133,9 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
),
@Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2"
),
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro"
),
@@ -23,6 +23,7 @@ import eu.darken.capod.pods.core.apple.beats.BeatsX
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
@@ -52,6 +53,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
@@ -43,7 +43,7 @@ data class PowerBeatsPro(
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
@@ -74,7 +74,7 @@ data class PowerBeatsPro(
}
companion object {
private val DEVICE_CODE_DIRTY = 11.toUByte()
private val DEVICE_CODE = 0x0B20.toUShort()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro")
}
}
@@ -0,0 +1,80 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class PowerBeatsPro2(
override val identifier: PodDevice.Id = PodDevice.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 flags: ApplePods.Flags,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO2
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
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 fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
flags: ApplePods.Flags
): ApplePods {
var basic = PowerBeatsPro2(scanResult = scanResult, payload = payload, flags = flags)
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,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x1D20.toUShort()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro2")
}
}
@@ -0,0 +1,61 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeatsPro2Test : BaseAirPodsTest() {
@Test
fun `test PowerBeatsPro2`() = runTest {
create<PowerBeatsPro2>("07 19 01 1D 20 2B AA 8F 01 02 04 30 42 6F CE 74 EE ED 56 AF 4E 31 16 9D 00 D7 DE") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1D20.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x02.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.RED.name
model shouldBe PodDevice.Model.POWERBEATS_PRO2
}
}
@Test
fun `test PowerBeatsPro2 - variant 2`() = runTest {
create<PowerBeatsPro2>("07 19 01 1D 20 73 AA 98 32 02 04 7D 47 B0 15 DE 5F B6 6B CB 46 F9 16 5C 88 4F 4E") {
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.RED.name
model shouldBe PodDevice.Model.POWERBEATS_PRO2
}
}
}