Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for AirPods (Gen 4 - no ANC) #227

Merged
merged 1 commit into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ interface PodDevice {
"AirPods (Gen 3)",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.devic_airpods_pro2_both
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,27 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.*
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
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.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2

@InstallIn(SingletonComponent::class)
@Module
Expand All @@ -16,6 +34,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory<out ApplePods>

@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package eu.darken.capod.pods.core.apple.airpods

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.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject

data class AirPodsGen4(
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,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {

override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4

override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage

override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState

override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi

class Factory @Inject constructor() : DualApplePodsFactory(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 = AirPodsGen4(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,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.rssiSmoothed(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}

}

companion object {
private val DEVICE_CODE = 0x1920.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen4")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package eu.darken.capod.pods.core.apple.airpods

import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
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 AirPodsGen4Test : BaseAirPodsTest() {

@Test
fun `AirPods Gen4 via log from #225`() = runTest {
create<AirPodsGen4>("07 19 01 19 20 2B 33 8F 11 00 04 59 D4 57 20 0F 1C 13 38 B2 00 74 E9 DD 70 D7 A5") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1920.toUShort()
rawStatus shouldBe 0x2B.toUByte()
rawPodsBattery shouldBe 0x33.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x11.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x04.toUByte()

batteryLeftPodPercent shouldBe 0.3f
batteryRightPodPercent shouldBe 0.3f

isCaseCharging shouldBe false
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false

isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null

caseLidState shouldBe DualApplePods.LidState.UNKNOWN

state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE

podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name

model shouldBe PodDevice.Model.AIRPODS_GEN4
}
}
}
Loading