From 354ac0164ebad0c5d1875c943904a81d72feace4 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 24 Sep 2026 15:57:39 +0200 Subject: [PATCH] feat(device): Add Beats 360 support Beats 360 (A3577) advertises proximity pairing frames with model code 0x3820, which no factory claimed, so it showed up as an unknown device. Decode it as a single headset with ANC control, like Beats Studio Pro. --- README.md | 1 + .../darken/capod/pods/core/apple/PodModel.kt | 8 +++ .../pods/core/apple/ble/AppleFactoryModule.kt | 2 + .../core/apple/ble/devices/beats/Beats360.kt | 69 +++++++++++++++++++ .../pods/core/apple/ModelFeaturesTest.kt | 2 + .../apple/ble/devices/beats/Beats360Test.kt | 24 +++++++ 6 files changed, 106 insertions(+) create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360Test.kt diff --git a/README.md b/README.md index 86357f7b5..5293f801f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Currently supported models: * Beats Studio Buds * Beats Studio Buds+ * Beats Studio Pro +* Beats 360 * Beats X * Beats Flex * Beats Fit Pro diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index 5b1271892..06f6fc585 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -469,6 +469,14 @@ enum class PodModel( modelNumbers = setOf("A2924"), // headphones ), + @SerialName("beats.360") + BEATS_360( + "Beats 360", + R.drawable.device_beats_headphones, + Features(hasAncControl = true), + modelNumbers = setOf("A3577"), // headphones + ), + @SerialName("beats.x") BEATS_X( "Beats X", diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt index f4142d84f..17341d5e8 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactoryModule.kt @@ -20,6 +20,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2 import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2Usbc import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro3 +import eu.darken.capod.pods.core.apple.ble.devices.beats.Beats360 import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFitPro import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFlex import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSolo3 @@ -76,6 +77,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun beatsStudioBuds(factory: BeatsStudioBuds.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsStudioBudsPlus(factory: BeatsStudioBudsPlus.Factory): ApplePodsFactory @Binds @IntoSet abstract fun beatsStudioPro(factory: BeatsStudioPro.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun beats360(factory: Beats360.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360.kt new file mode 100644 index 000000000..30bc287d7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360.kt @@ -0,0 +1,69 @@ +package eu.darken.capod.pods.core.apple.ble.devices.beats + +import eu.darken.capod.common.SystemTimeSource +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods +import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory +import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods +import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing +import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload +import java.time.Instant +import javax.inject.Inject + +data class Beats360( + override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(), + override val seenLastAt: Instant = SystemTimeSource.now(), + override val seenFirstAt: Instant = SystemTimeSource.now(), + override val seenCounter: Int = 1, + override val scanResult: BleScanResult, + override val payload: ProximityPayload, + override val meta: ApplePods.AppleMeta, + override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, +) : SingleApplePods { + + override val model: PodModel = PodModel.BEATS_360 + 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 suspend fun create( + scanResult: BleScanResult, + payload: ProximityPayload, + meta: ApplePods.AppleMeta + ): ApplePods { + var basic = Beats360(scanResult = scanResult, payload = payload, meta = meta) + 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, + rssiAverage = result.rssiSmoothed(basic.rssi), + ) + } + } + + companion object { + private val DEVICE_CODE = 0x3820.toUShort() + private val TAG = logTag("PodDevice", "Beats", "360") + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt index 956335bf0..144bb1978 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ModelFeaturesTest.kt @@ -28,6 +28,7 @@ class ModelFeaturesTest : BaseTest() { PodModel.BEATS_STUDIO_BUDS, PodModel.BEATS_STUDIO_BUDS_PLUS, PodModel.BEATS_STUDIO_PRO, + PodModel.BEATS_360, PodModel.BEATS_X, PodModel.POWERBEATS_3, PodModel.POWERBEATS_4, @@ -265,6 +266,7 @@ class ModelFeaturesTest : BaseTest() { PodModel.BEATS_STUDIO_BUDS, PodModel.BEATS_STUDIO_BUDS_PLUS, PodModel.BEATS_STUDIO_PRO, + PodModel.BEATS_360, PodModel.POWERBEATS_PRO2, PodModel.BEATS_FIT_PRO, PodModel.FAKE_AIRPODS_PRO, diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360Test.kt new file mode 100644 index 000000000..d27caa09e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/beats/Beats360Test.kt @@ -0,0 +1,24 @@ +package eu.darken.capod.pods.core.apple.ble.devices.beats + +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.BaseBlePodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class Beats360Test : BaseBlePodsTest() { + + @Test + fun `default Beats360`() = runTest { + create("07 19 01 38 20 00 07 80 04 02 00 01 7D B1 76 1F 5C D6 93 D7 8B 71 77 0A 81 7B AD") { + pubDeviceModel shouldBe 0x3820.toUShort() + batteryHeadsetPercent shouldBe 0.7f + model shouldBe PodModel.BEATS_360 + } + } + + @Test + fun `model number maps to Beats 360`() { + PodModel.fromModelNumber("A3577") shouldBe PodModel.BEATS_360 + } +}