From 6db50d0c297ab255c611f643808399a8ec520494 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 25 Feb 2024 13:03:32 +0100 Subject: [PATCH 1/2] Add support for PowerBeats4 Thanks to @docgalaxyblock --- .../eu/darken/capod/pods/core/PodDevice.kt | 3 + .../pods/core/apple/AppleFactoryModule.kt | 1 + .../pods/core/apple/beats/PowerBeats4.kt | 60 +++++++++++++++ .../pods/core/apple/beats/BeatsFitProTest.kt | 29 +++++++ .../pods/core/apple/beats/PowerBeats4Test.kt | 75 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 app-common/src/main/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4.kt create mode 100644 app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index 6b917322..94c69e99 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -114,6 +114,9 @@ interface PodDevice { @Json(name = "beats.powerbeats.3") POWERBEATS_3( "Power Beats 3" ), + @Json(name = "beats.powerbeats.4") POWERBEATS_4( + "Power Beats 4" + ), @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( "Power Beats Pro" ), diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt index d2d7a14d..bd42cfe4 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt @@ -27,6 +27,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory @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 beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4.kt new file mode 100644 index 00000000..02b30b13 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4.kt @@ -0,0 +1,60 @@ +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.SingleApplePods +import eu.darken.capod.pods.core.apple.SingleApplePodsFactory +import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods +import eu.darken.capod.pods.core.apple.protocol.ProximityPairing +import java.time.Instant +import javax.inject.Inject + +data class PowerBeats4( + 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 proximityMessage: ProximityPairing.Message, + override val reliability: Float = PodDevice.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, +) : SingleApplePods, HasStateDetectionAirPods { + + override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_4 + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + class Factory @Inject constructor() : SingleApplePodsFactory(TAG) { + + override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run { + getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH + } + + override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods { + var basic = PowerBeats4(scanResult = scanResult, proximityMessage = message) + val result = searchHistory(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + 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 = 0x0D20.toUShort() + private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "4") + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt index ffee6a3c..a01d9a49 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt @@ -40,4 +40,33 @@ class BeatsFitProTest : BaseAirPodsTest() { model shouldBe PodDevice.Model.BEATS_FIT_PRO } } + + @Test + fun `extra rl test case`() = runTest { + create("07 19 01 12 20 04 FA 92 54 11 24 CE B1 DF 9D 8D F5 E3 37 60 B1 23 8B 90 3B 63 3F") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x1220.toUShort() + rawStatus shouldBe 0x04.toUByte() + rawPodsBattery shouldBe 0xFA.toUByte() + rawFlags shouldBe 0x9.toUShort() + rawCaseBattery shouldBe 0x2.toUShort() + rawCaseLidState shouldBe 0x54.toUByte() + rawDeviceColor shouldBe 0x11.toUByte() + rawSuffix shouldBe 0x24.toUByte() + + isLeftPodMicrophone shouldBe false + isRightPodMicrophone shouldBe true + + batteryLeftPodPercent shouldBe null + batteryRightPodPercent shouldBe 1.0f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe 0.2f + podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name + + model shouldBe PodDevice.Model.BEATS_FIT_PRO + } + } } \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt new file mode 100644 index 00000000..e44f69bc --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt @@ -0,0 +1,75 @@ +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.airpods.HasStateDetectionAirPods +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class PowerBeats4Test : BaseAirPodsTest() { + + @Test + fun `playing music`() = runTest { + create("07 19 01 0D 20 00 02 80 01 00 05 07 C5 E1 9C BD 9A 05 0E AE 9E 56 53 2F F9 75 4A") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0D20.toUShort() + rawStatus shouldBe 0x0.toUByte() + rawPodsBattery shouldBe 0x02.toUByte() + rawFlags shouldBe 0x8.toUShort() + rawCaseBattery shouldBe 0x0.toUShort() + rawCaseLidState shouldBe 0x01.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x05.toUByte() + + batteryHeadsetPercent shouldBe 0.2f + + model shouldBe PodDevice.Model.POWERBEATS_4 + + state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC + } + } + + @Test + fun `extra test case`() = runTest { + create("07 19 01 0D 20 00 02 80 02 00 04 80 90 60 77 32 C2 0C 75 7A 3D 7E D7 1C 0E B8 43") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0D20.toUShort() + rawStatus shouldBe 0x0.toUByte() + rawPodsBattery shouldBe 0x02.toUByte() + rawFlags shouldBe 0x8.toUShort() + rawCaseBattery shouldBe 0x0.toUShort() + rawCaseLidState shouldBe 0x02.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x04.toUByte() + + batteryHeadsetPercent shouldBe 0.2f + + model shouldBe PodDevice.Model.POWERBEATS_4 + + state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE + } + } + + + @Test + fun `disconnected state`() = runTest { + create("07 19 01 0D 20 00 02 80 01 00 00 25 DF 66 40 AF 44 7B 77 95 8F D1 92 50 26 11 74") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0D20.toUShort() + rawStatus shouldBe 0x0.toUByte() + rawPodsBattery shouldBe 0x02.toUByte() + rawFlags shouldBe 0x8.toUShort() + rawCaseBattery shouldBe 0x0.toUShort() + rawCaseLidState shouldBe 0x01.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x00.toUByte() + + batteryHeadsetPercent shouldBe 0.2f + + model shouldBe PodDevice.Model.POWERBEATS_4 + + state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED + } + } +} \ No newline at end of file From da62075b710bdb766ecad786e560dd1e21aaad01 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 25 Feb 2024 13:12:22 +0100 Subject: [PATCH 2/2] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 19c2fd05..9715c299 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Currently supported models: * AirPods Max * Power Beats Pro * Power Beats 3 +* Power Beats 4 * Beats Solo 3 * Beats Studio 3 * Beats X