Refactor proximity message handling, introduce "Payload" that splits the data in public and private part.

This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent a434a0bee0
commit 3b00613dde
66 changed files with 706 additions and 1040 deletions
@@ -19,7 +19,6 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.monitor.core.RPAChecker
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -202,7 +201,7 @@ class PodMonitor @Inject constructor(
private fun determineMainDevice(pods: List<PodDevice>): PodDevice? {
generalSettings.mainDeviceIdentityKey.value
.also { log(TAG) { "Identity-Resolving-Key (IRK): ${it?.toByteString()}" } }
?.let { irkKey -> pods.firstOrNull { pod -> rpaChecker.verify(pod, irkKey) } }
?.let { irkKey -> pods.firstOrNull { pod -> rpaChecker.verify(pod.address, irkKey) } }
?.let {
log(TAG) { "Main device determined via IRK: $it" }
return it
@@ -5,7 +5,6 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
@@ -14,14 +13,14 @@ import javax.inject.Inject
class RPAChecker @Inject constructor() {
// Resolvable-Private-Address
fun verify(dev: PodDevice, irk: ByteArray): Boolean = try {
val rpa = dev.address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
fun verify(address: String, irk: ByteArray): Boolean = try {
val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
hash.contentEquals(computedHash)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to verify RPA\nPod=${dev}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
log(TAG, ERROR) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
false
}
@@ -6,11 +6,13 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.RPAChecker
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.MessageDecrypter
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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -20,15 +22,16 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val generalSettings: GeneralSettings,
private val messageDecrypter: MessageDecrypter,
private val rpaChecker: RPAChecker,
) {
private val lock = Mutex()
private fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
private fun getMessage(scanResult: BleScanResult): ProximityMessage? {
val messages = try {
continuityProtocolDecoder.decode(scanResult)
} catch (e: Exception) {
@@ -54,20 +57,40 @@ class AppleFactory @Inject constructor(
}
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
val pm = getMessage(scanResult) ?: return@withLock null
val proximityMessage = getMessage(scanResult) ?: return@withLock null
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) }
val decryptedData = generalSettings.mainDeviceEncryptionKey.value
?.let { messageDecrypter.decrypt(pm, it) }
val payload = getPayload(scanResult, proximityMessage)
return@withLock (factory ?: unknownAppleFactory).create(
scanResult = scanResult,
message = pm,
decrypted = decryptedData,
payload = payload,
)
}
private fun getPayload(
scanResult: BleScanResult,
proximityMessage: ProximityMessage
) = ProximityPayload(
public = ProximityPayload.Public(
proximityMessage.data.take(16).toUByteArray()
),
private = run {
val irkKey = generalSettings.mainDeviceIdentityKey.value
if (irkKey == null) return@run null
val encKey = generalSettings.mainDeviceEncryptionKey.value
if (encKey == null) return@run null
if (!rpaChecker.verify(scanResult.address, irkKey)) return@run null
val encrypted = proximityMessage.data.takeLast(16).toUByteArray().toByteArray()
proximityMessageDecrypter.decrypt(encrypted, encKey)?.let {
ProximityPayload.Private(data = it)
}
},
)
companion object {
private val TAG = logTag("Pod", "Apple", "Factory")
}
@@ -1,51 +1,39 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
interface ApplePods : PodDevice {
val proximityMessage: ProximityPairing.Message
val decryptedPayload: UByteArray?
get() = null
val payload: ProximityPayload
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val pubPrefix: UByte
get() = payload.public.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val pubDeviceModel: UShort
get() = (((payload.public.data[1].toInt() and 255) shl 8) or (payload.public.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val pubStatus: UByte
get() = payload.public.data[3]
val rawStatusHex: String
get() = rawStatus.toHex()
val pubPodsBattery: UByte
get() = payload.public.data[4]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val pubFlags: UShort
get() = payload.public.data[5].upperNibble
val rawPodsBatteryHex: String
get() = rawPodsBattery.toHex()
val pubCaseBattery: UShort
get() = payload.public.data[5].lowerNibble
val rawFlags: UShort
get() = proximityMessage.data[5].upperNibble
val rawCaseBattery: UShort
get() = proximityMessage.data[5].lowerNibble
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
val rawSuffix: UByte
get() = proximityMessage.data[8]
val pubCaseLidState: UByte
get() = payload.public.data[6]
val pubDeviceColor: UByte
get() = payload.public.data[7]
val pubSuffix: UByte
get() = payload.public.data[8]
}
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple
import android.R.id.message
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.collections.median
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -9,30 +10,27 @@ import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Duration
import java.time.Instant
import kotlin.math.max
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
data class Markings(
val vendor: UByte,
val length: UByte,
data class Identifier(
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
private fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
private fun ProximityPayload.getFuzzyIdentifier(): Identifier = Identifier(
device = (((public.data[1].toInt() and 255) shl 8) or (public.data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
podBatteryData = setOf(public.data[4].upperNibble, public.data[4].lowerNibble),
caseBatteryData = public.data[5].lowerNibble,
deviceColor = public.data[7]
)
data class KnownDevice(
@@ -42,8 +40,8 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
val lastPayload: ProximityPayload
get() = history.last().payload
val lastAddress: String
get() = history.last().address
@@ -127,7 +125,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
val payload = current.payload
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(30))) {
@@ -148,9 +146,9 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getApplePodsMarkings()
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
@@ -173,6 +171,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
)
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
val history = listOf(device)
@@ -192,16 +191,15 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(message: ProximityPairing.Message): Boolean
abstract fun isResponsible(message: ProximityMessage): Boolean
abstract fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?,
payload: ProximityPayload,
): ApplePods
}
@@ -15,7 +15,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) {
get() = when (pubStatus.isBitSet(5)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
@@ -25,17 +25,17 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
get() = !pubStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
decryptedPayload?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
payload.private?.data?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
true -> pubPodsBattery.upperNibble.toInt()
false -> pubPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
@@ -50,13 +50,13 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
override val batteryRightPodPercent: Float?
get() {
decryptedPayload?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
payload.private?.data?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
true -> pubPodsBattery.lowerNibble.toInt()
false -> pubPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
@@ -70,24 +70,24 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
}
val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
get() = pubStatus.isBitSet(6)
val isOnePodInCase: Boolean
get() = rawStatus.isBitSet(4)
get() = pubStatus.isBitSet(4)
val areBothPodsInCase: Boolean
get() = rawStatus.isBitSet(2)
get() = pubStatus.isBitSet(2)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
true -> pubStatus.isBitSet(3)
false -> pubStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
true -> pubStatus.isBitSet(1)
false -> pubStatus.isBitSet(3)
}
/**
@@ -95,46 +95,46 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
get() = pubStatus.isBitSet(5) xor isThisPodInThecase
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
get() = !pubStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodCharging: Boolean
get() {
decryptedPayload?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
payload.private?.data?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
return isCharging
}
return when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
false -> pubFlags.isBitSet(0)
true -> pubFlags.isBitSet(1)
}
}
override val isRightPodCharging: Boolean
get() {
decryptedPayload?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
payload.private?.data?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
return isCharging
}
return when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
false -> pubFlags.isBitSet(1)
true -> pubFlags.isBitSet(0)
}
}
override val batteryCasePercent: Float?
get() {
decryptedPayload?.get(3)?.let { raw ->
payload.private?.data?.get(3)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
return when (val value = rawCaseBattery.toInt()) {
return when (val value = pubCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
@@ -147,18 +147,18 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
override val isCaseCharging: Boolean
get() {
decryptedPayload?.get(3)?.let { raw ->
payload.private?.data?.get(3)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
if (batteryCasePercent != null) return isCharging
}
return rawFlags.isBitSet(2)
return pubFlags.isBitSet(2)
}
val caseLidState: LidState
get() {
val rawstate = rawCaseLidState
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
val rawstate = pubCaseLidState
return LidState.entries.firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
}
/**
@@ -13,7 +13,7 @@ abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = rawDeviceColor,
color = pubDeviceColor,
model = model
)
@@ -6,8 +6,8 @@ import eu.darken.capod.pods.core.HasPodStyle
interface HasAppleColor : ApplePods, HasPodStyle {
override val podStyle: HasPodStyle.PodStyle
get() = DeviceColor.values()
.firstOrNull { it.raw == rawDeviceColor }
get() = DeviceColor.entries
.firstOrNull { it.raw == pubDeviceColor }
?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) : HasPodStyle.PodStyle {
@@ -10,7 +10,7 @@ import eu.darken.capod.pods.core.SinglePodDevice
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
override val batteryHeadsetPercent: Float?
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
get() = when (val value = pubPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Headset above 100% battery: $value" }
@@ -6,17 +6,19 @@ 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.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 AirPodsGen1 constructor(
data class AirPodsGen1(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,17 +38,16 @@ data class AirPodsGen1 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsGen1(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,17 +6,19 @@ 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.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 AirPodsGen2 constructor(
data class AirPodsGen2(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class AirPodsGen2 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsGen2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,17 +6,19 @@ 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.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 AirPodsGen3 constructor(
data class AirPodsGen3(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class AirPodsGen3 constructor(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsGen3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class AirPodsGen4(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class AirPodsGen4(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen4(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsGen4(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class AirPodsGen4Anc(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class AirPodsGen4Anc(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen4Anc(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsGen4Anc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -10,7 +10,9 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
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
@@ -20,7 +22,7 @@ data class AirPodsMax(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
@@ -31,23 +33,22 @@ data class AirPodsMax(
get() = rssiAverage ?: super<SingleApplePods>.rssi
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
get() = pubFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = rawStatus.isBitSet(5)
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsMax(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -10,7 +10,9 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
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
@@ -20,7 +22,7 @@ data class AirPodsMaxUsbc(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
@@ -31,23 +33,22 @@ data class AirPodsMaxUsbc(
get() = rssiAverage ?: super<SingleApplePods>.rssi
override val isHeadsetBeingCharged: Boolean
get() = rawFlags.isBitSet(0)
get() = pubFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = rawStatus.isBitSet(5)
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsMaxUsbc(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsMaxUsbc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -9,7 +9,9 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
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
@@ -19,8 +21,7 @@ data class AirPodsPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val decryptedPayload: UByteArray?,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -56,19 +57,17 @@ data class AirPodsPro(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsPro(
scanResult = scanResult,
proximityMessage = message,
decryptedPayload = decrypted
payload = payload,
)
val result = searchHistory(basic)
@@ -9,7 +9,9 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
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
@@ -19,7 +21,7 @@ data class AirPodsPro2(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -55,16 +57,15 @@ data class AirPodsPro2(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsPro2(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsPro2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -9,7 +9,9 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
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
@@ -19,7 +21,7 @@ data class AirPodsPro2Usbc(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -55,16 +57,15 @@ data class AirPodsPro2Usbc(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = AirPodsPro2Usbc(scanResult = scanResult, proximityMessage = message)
var basic = AirPodsPro2Usbc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -9,7 +9,7 @@ import eu.darken.capod.pods.core.apple.ApplePods
interface HasStateDetectionAirPods : HasStateDetection, ApplePods {
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
get() = ConnectionState.entries.firstOrNull { pubSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class BeatsFitPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class BeatsFitPro(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
var basic = BeatsFitPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class BeatsFlex(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -28,16 +30,15 @@ data class BeatsFlex(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
var basic = BeatsFlex(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class BeatsSolo3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -27,16 +29,15 @@ data class BeatsSolo3(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
var basic = BeatsSolo3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class BeatsStudio3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -25,16 +27,15 @@ data class BeatsStudio3(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
var basic = BeatsStudio3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class BeatsX(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -28,16 +30,15 @@ data class BeatsX(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
var basic = BeatsX(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class PowerBeats3(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -28,16 +30,15 @@ data class PowerBeats3(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
var basic = PowerBeats3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -7,7 +7,9 @@ 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.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
@@ -17,7 +19,7 @@ data class PowerBeats4(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasStateDetectionAirPods {
@@ -29,16 +31,15 @@ data class PowerBeats4(
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = PowerBeats4(scanResult = scanResult, proximityMessage = message)
var basic = PowerBeats4(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -6,7 +6,9 @@ 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.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
@@ -16,7 +18,7 @@ data class PowerBeatsPro(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -36,16 +38,15 @@ data class PowerBeatsPro(
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
var basic = PowerBeatsPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -18,118 +19,38 @@ import javax.inject.Inject
* Similar data structure but a lot of placeholder values or hardcoded values
* Marketed as TWS i99999
*/
data class FakeAirPodsGen1 constructor(
data class FakeAirPodsGen1(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen1(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -1,133 +1,54 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
/**
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class FakeAirPodsGen2 constructor(
data class FakeAirPodsGen2(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -18,118 +19,38 @@ import javax.inject.Inject
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
*/
data class FakeAirPodsGen3 constructor(
data class FakeAirPodsGen3(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen3>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -3,15 +3,16 @@ package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -20,17 +21,17 @@ import javax.inject.Inject
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/
data class FakeAirPodsPro constructor(
data class FakeAirPodsPro(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
) : DualApplePods, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
@@ -51,103 +52,23 @@ data class FakeAirPodsPro constructor(
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -3,16 +3,18 @@ package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -20,18 +22,18 @@ import javax.inject.Inject
* AirPods Pro2 clone
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
*/
data class FakeAirPodsPro2 constructor(
data class FakeAirPodsPro2(
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 payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null,
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
private val cachedCaseState: LidState? = null,
) : DualApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
@@ -52,99 +54,25 @@ data class FakeAirPodsPro2 constructor(
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: scanResult.rssi
private val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
get() = rssiAverage ?: super<DualApplePods>.rssi
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsPro2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -7,7 +7,8 @@ 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.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
@@ -17,7 +18,7 @@ data class UnknownAppleDevice(
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val payload: ProximityPayload,
override val reliability: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods {
@@ -30,14 +31,13 @@ data class UnknownAppleDevice(
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = true
override fun isResponsible(message: ProximityMessage): Boolean = true
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?,
payload: ProximityPayload,
): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
var basic = UnknownAppleDevice(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -1,44 +0,0 @@
package eu.darken.capod.pods.core.apple.protocol
import android.annotation.SuppressLint
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
class MessageDecrypter @Inject constructor() {
@SuppressLint("GetInstance")
fun decrypt(message: ProximityPairing.Message, key: ByteArray): UByteArray? {
val data = message.data.toByteArray()
if (data.size < 16) return null
val decryptedData = try {
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
val secretKey = SecretKeySpec(key, "AES")
init(Cipher.DECRYPT_MODE, secretKey)
}
cipher.doFinal(data.copyOfRange(data.size - 16, data.size))
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
null
}
log(
TAG,
Logging.Priority.VERBOSE
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
if (decryptedData == null || decryptedData.size != 16) return null
return decryptedData.toUByteArray()
}
companion object {
private val TAG = logTag("Pod", "Factory", "Decrypter")
}
}
@@ -0,0 +1,58 @@
package eu.darken.capod.pods.core.apple.protocol
import android.R.id.message
import android.annotation.SuppressLint
import dagger.Reusable
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
data class ProximityMessage(
val type: UByte,
val length: Int,
val data: UByteArray
) {
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
}
@Reusable
class Decrypter @Inject constructor() {
@SuppressLint("GetInstance")
fun decrypt(data: ByteArray, key: ByteArray): UByteArray? {
val decryptedData = try {
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
val secretKey = SecretKeySpec(key, "AES")
init(Cipher.DECRYPT_MODE, secretKey)
}
cipher.doFinal(data.copyOfRange(data.size - 16, data.size))
} catch (e: Exception) {
log(
TAG,
Logging.Priority.ERROR
) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
null
}
log(
TAG,
Logging.Priority.VERBOSE
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
if (decryptedData == null || decryptedData.size != 16) return null
return decryptedData.toUByteArray()
}
companion object {
private val TAG = logTag("Pod", "Factory", "Decrypter")
}
}
}
@@ -8,27 +8,15 @@ import javax.inject.Inject
object ProximityPairing {
data class Message(
val type: UByte,
val length: Int,
val data: UByteArray
) {
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
}
}
@Reusable
class Decoder @Inject constructor() {
fun decode(message: ContinuityProtocol.Message): Message? {
fun decode(message: ContinuityProtocol.Message): ProximityMessage? {
if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) {
log { "Not a proximity pairing message: $this" }
return null
}
return Message(
return ProximityMessage(
type = message.type,
length = message.length,
data = message.data
@@ -0,0 +1,15 @@
package eu.darken.capod.pods.core.apple.protocol
data class ProximityPayload(
val public: Public,
val private: Private?,
) {
data class Public(
val data: UByteArray,
)
data class Private(
val data: UByteArray,
)
}
@@ -51,8 +51,8 @@ class PopUpReaction @Inject constructor(
return@mapNotNull null
}
log(TAG, VERBOSE) {
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
val prev = previous?.pubCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.pubCaseLidState.let { String.format("%02X", it.toByte()) }
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
}
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
@@ -1,28 +1,35 @@
package eu.darken.capod.pods.core.apple
import dagger.BindsInstance
import dagger.Component
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.serialization.SerializationModule
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import org.junit.jupiter.api.BeforeEach
import testhelpers.BaseTest
import testhelpers.preferences.mockFlowPreference
import java.time.Instant
import javax.inject.Singleton
abstract class BaseAirPodsTest : BaseTest() {
@Singleton
@Component(modules = [AppleFactoryModule::class])
@Component(modules = [AppleFactoryModule::class, SerializationModule::class])
interface AppleFactoryTestComponent {
val appleFactory: AppleFactory
@Component.Factory
interface Factory {
fun create(): AppleFactoryTestComponent
fun create(
@BindsInstance generalSettings: GeneralSettings,
): AppleFactoryTestComponent
}
}
@@ -34,7 +41,14 @@ abstract class BaseAirPodsTest : BaseTest() {
manufacturerSpecificData = emptyMap()
)
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create().appleFactory
val generalSettings = mockk<GeneralSettings>().apply {
every { mainDeviceIdentityKey } returns mockFlowPreference(null)
every { mainDeviceEncryptionKey } returns mockFlowPreference(null)
}
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create(
generalSettings = generalSettings
).appleFactory
@BeforeEach
fun setup() {
@@ -9,15 +9,15 @@ class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `test mapping`() = runTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
rawStatus shouldBe 0x00.toUByte()
rawPodsBattery shouldBe 0xF5.toUByte()
rawFlags shouldBe 0x0.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0520.toUShort()
pubStatus shouldBe 0x00.toUByte()
pubPodsBattery shouldBe 0xF5.toUByte()
pubFlags shouldBe 0x0.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
}
}
@@ -11,15 +11,15 @@ class DualApplePodsTest : BaseAirPodsTest() {
fun `test bit mapping`() = runTest {
create<DualApplePods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0e20.toUShort()
rawStatus shouldBe 0x54.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0e20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
}
}
@@ -9,15 +9,15 @@ class SingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `default bit mapping Max`() = runTest {
create<SingleApplePods>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
}
}
}
@@ -14,15 +14,15 @@ class AirPodsGen1Test : BaseAirPodsTest() {
@Test
fun `fake airpods`() = runTest {
create<AirPodsGen1>("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x55.toUByte()
rawPodsBattery shouldBe 0xAF.toUByte()
rawFlags shouldBe 0x5.toUShort()
rawCaseBattery shouldBe 0x6.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0220.toUShort()
pubStatus shouldBe 0x55.toUByte()
pubPodsBattery shouldBe 0xAF.toUByte()
pubFlags shouldBe 0x5.toUShort()
pubCaseBattery shouldBe 0x6.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe null
@@ -13,15 +13,15 @@ class AirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runTest {
create<AirPodsGen2>("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
rawStatus shouldBe 0x02.toUByte()
rawPodsBattery shouldBe 0xF9.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x05.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0F20.toUShort()
pubStatus shouldBe 0x02.toUByte()
pubPodsBattery shouldBe 0xF9.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x05.toUByte()
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.9f
@@ -13,15 +13,15 @@ class AirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen3`() = runTest {
create<AirPodsGen3>("07 19 01 13 20 75 AA B9 31 00 04 67 9A 57 DF BE F6 90 52 B0 04 1F 8D 89 DA F4 9E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x9.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x04.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1320.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x9.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -13,15 +13,15 @@ class AirPodsGen4AncTest : BaseAirPodsTest() {
@Test
fun `AirPods Gen4 with ANC via log from #226`() = runTest {
create<AirPodsGen4Anc>("07 19 01 1B 20 0B 9A 8F 10 00 04 43 DF EC 1D D3 F1 C3 F4 A1 9B 29 26 B9 E7 3A A0") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1B20.toUShort()
rawStatus shouldBe 0x0b.toUByte()
rawPodsBattery shouldBe 0x9A.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x10.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x04.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1B20.toUShort()
pubStatus shouldBe 0x0b.toUByte()
pubPodsBattery shouldBe 0x9A.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x10.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 1.0f
@@ -13,15 +13,15 @@ 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()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1920.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x33.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x11.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 0.3f
batteryRightPodPercent shouldBe 0.3f
@@ -14,15 +14,15 @@ class AirPodsMaxTest : BaseAirPodsTest() {
@Test
fun `default AirPods Max`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
@@ -36,15 +36,15 @@ class AirPodsMaxTest : BaseAirPodsTest() {
@Test
fun `default AirPods Max flipped values`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x02.toUByte()
rawPodsBattery shouldBe 0x05.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x04.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x44.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x02.toUByte()
pubPodsBattery shouldBe 0x05.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x04.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x44.toUByte()
batteryHeadsetPercent shouldBe 0.5f
@@ -55,21 +55,21 @@ class AirPodsMaxTest : BaseAirPodsTest() {
@Test
fun `some dude at the gym`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x23.toUByte()
rawPodsBattery shouldBe 0x07.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x03.toUByte()
rawDeviceColor shouldBe 0x03.toUByte()
rawSuffix shouldBe 0x65.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x23.toUByte()
pubPodsBattery shouldBe 0x07.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x03.toUByte()
pubDeviceColor shouldBe 0x03.toUByte()
pubSuffix shouldBe 0x65.toUByte()
batteryHeadsetPercent shouldBe 0.7f
isHeadsetBeingCharged shouldBe false
rawStatus.isBitSet(5) shouldBe true
pubStatus.isBitSet(5) shouldBe true
podStyle shouldBe HasAppleColor.DeviceColor.BLUE
}
@@ -78,15 +78,15 @@ class AirPodsMaxTest : BaseAirPodsTest() {
@Test
fun `wear status`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 03 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x03.toUByte()
pubStatus shouldBe 0x03.toUByte()
rawStatus.isBitSet(5) shouldBe false
pubStatus.isBitSet(5) shouldBe false
isBeingWorn shouldBe false
}
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
rawStatus shouldBe 0x23.toUByte()
pubStatus shouldBe 0x23.toUByte()
rawStatus.isBitSet(5) shouldBe true
pubStatus.isBitSet(5) shouldBe true
isBeingWorn shouldBe true
}
}
@@ -12,15 +12,15 @@ class AirPodsMaxUsbcTest : BaseAirPodsTest() {
@Test
fun `default AirPods Max`() = runTest {
create<AirPodsMaxUsbc>("07 19 01 1F 20 2B 05 80 03 12 C5 2E 8B F9 9A 7E 19 7B 63 0F 30 6E D7 3B E2 EC 32") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1F20.toUShort()
rawStatus shouldBe 0x2B.toUByte()
rawPodsBattery shouldBe 0x05.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x03.toUByte()
rawDeviceColor shouldBe 0x12.toUByte()
rawSuffix shouldBe 0xC5.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1F20.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x05.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x03.toUByte()
pubDeviceColor shouldBe 0x12.toUByte()
pubSuffix shouldBe 0xC5.toUByte()
batteryHeadsetPercent shouldBe 0.5f
@@ -16,15 +16,15 @@ class AirPodsPro2Test : BaseAirPodsTest() {
fun `test AirPods Pro 2 - unknown setup from #31`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 55 88 F9 51 00 04 20 50 03 CA D5 C9 AC 0F FA 84 78 94 5A 4D DF F5") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x55.toUByte()
rawPodsBattery shouldBe 0x88.toUByte()
rawFlags shouldBe 0xF.toUShort()
rawCaseBattery shouldBe 0x9.toUShort()
rawCaseLidState shouldBe 0x51.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x55.toUByte()
pubPodsBattery shouldBe 0x88.toUByte()
pubFlags shouldBe 0xF.toUShort()
pubCaseBattery shouldBe 0x9.toUShort()
pubCaseLidState shouldBe 0x51.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
@@ -53,15 +53,15 @@ class AirPodsPro2Test : BaseAirPodsTest() {
fun `test AirPods Pro 2 - unknown setup from reddit user`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 2B 9A 8F 01 00 04 0F 26 1A C4 2B FA 2F B9 B6 08 CD 60 CB DF 75 AB") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x2B.toUByte()
rawPodsBattery shouldBe 0x9A.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x9A.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
@@ -16,15 +16,15 @@ class AirPodsPro2UsbcTest : BaseAirPodsTest() {
fun `AirPods Pro 2 with USB-C - via #164`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x2420.toUShort()
rawStatus shouldBe 0x0B.toUByte()
rawPodsBattery shouldBe 0x99.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x11.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x2420.toUShort()
pubStatus shouldBe 0x0B.toUByte()
pubPodsBattery shouldBe 0x99.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x11.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
@@ -13,15 +13,15 @@ class AirPodsProTest : BaseAirPodsTest() {
fun `test AirPods Pro - default changed and in case`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0e20.toUShort()
rawStatus shouldBe 0x54.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0e20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
@@ -177,7 +177,7 @@ class AirPodsProTest : BaseAirPodsTest() {
@Test
fun `left pod has no data`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 0B F9 8F 03 00 05 5B 59 67 4C F7 F3 EF 01 BA F4 92 1B 26 E4 90 40") {
rawPodsBattery shouldBe 0xF9.toUByte()
pubPodsBattery shouldBe 0xF9.toUByte()
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.9f
@@ -15,15 +15,15 @@ class BeatsFitProTest : BaseAirPodsTest() {
@Test
fun `test basics`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 20 FA 8F 01 11 24 9B 9B 23 52 60 5A 8C 32 1C A5 C2 81 51 82 AF C8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1220.toUShort()
rawStatus shouldBe 0x20.toUByte()
rawPodsBattery shouldBe 0xFA.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x11.toUByte()
rawSuffix shouldBe 0x24.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1220.toUShort()
pubStatus shouldBe 0x20.toUByte()
pubPodsBattery shouldBe 0xFA.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x11.toUByte()
pubSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
@@ -44,15 +44,15 @@ class BeatsFitProTest : BaseAirPodsTest() {
@Test
fun `extra rl test case`() = runTest {
create<BeatsFitPro>("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()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1220.toUShort()
pubStatus shouldBe 0x04.toUByte()
pubPodsBattery shouldBe 0xFA.toUByte()
pubFlags shouldBe 0x9.toUShort()
pubCaseBattery shouldBe 0x2.toUShort()
pubCaseLidState shouldBe 0x54.toUByte()
pubDeviceColor shouldBe 0x11.toUByte()
pubSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
@@ -12,15 +12,15 @@ class BeatsFlexText : BaseAirPodsTest() {
@Test
fun `default BeatsFlex`() = runTest {
create<BeatsFlex>("07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1020.toUShort()
rawStatus shouldBe 0x0A.toUByte()
rawPodsBattery shouldBe 0xF4.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x00.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1020.toUShort()
pubStatus shouldBe 0x0A.toUByte()
pubPodsBattery shouldBe 0xF4.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x00.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f
@@ -12,15 +12,15 @@ class BeatsSolo3Test : BaseAirPodsTest() {
@Test
fun `default BeatsSolo3`() = runTest {
create<BeatsSolo3>("07 19 01 06 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0620.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0620.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
@@ -12,15 +12,15 @@ class BeatsStudio3Test : BaseAirPodsTest() {
@Test
fun `default BeatsStudio3`() = runTest {
create<BeatsStudio3>("07 19 01 09 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0920.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0920.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
@@ -12,15 +12,15 @@ class BeatsXTest : BaseAirPodsTest() {
@Test
fun `default BeatsX`() = runTest {
create<BeatsX>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
rawStatus shouldBe 0x00.toUByte()
rawPodsBattery shouldBe 0xF5.toUByte()
rawFlags shouldBe 0x0.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0520.toUShort()
pubStatus shouldBe 0x00.toUByte()
pubPodsBattery shouldBe 0xF5.toUByte()
pubFlags shouldBe 0x0.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f
@@ -12,15 +12,15 @@ class PowerBeats3Test : BaseAirPodsTest() {
@Test
fun `default PowerBeats3`() = runTest {
create<PowerBeats3>("07 19 01 03 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0320.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0320.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
@@ -12,15 +12,15 @@ class PowerBeats4Test : BaseAirPodsTest() {
@Test
fun `playing music`() = runTest {
create<PowerBeats4>("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()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x05.toUByte()
batteryHeadsetPercent shouldBe 0.2f
@@ -33,15 +33,15 @@ class PowerBeats4Test : BaseAirPodsTest() {
@Test
fun `extra test case`() = runTest {
create<PowerBeats4>("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()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x02.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryHeadsetPercent shouldBe 0.2f
@@ -55,15 +55,15 @@ class PowerBeats4Test : BaseAirPodsTest() {
@Test
fun `disconnected state`() = runTest {
create<PowerBeats4>("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()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.2f
@@ -13,15 +13,15 @@ class PowerBeatsProTest : BaseAirPodsTest() {
@Test
fun `test PowerBeatsPro`() = runTest {
create<PowerBeatsPro>("07 19 01 0B 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0B20.toUShort()
rawStatus shouldBe 0x54.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0B20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
@@ -11,15 +11,15 @@ class FakeAirPodsGen1Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen2>("07 13 01 0F 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x32.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0F20.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x32.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -11,15 +11,15 @@ class FakeAirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x32.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0220.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x32.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -11,15 +11,15 @@ class FakeAirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `charging in case`() = runTest {
create<FakeAirPodsGen3>("07 13 01 13 20 75 AA 37 34 00 10 00 E4 E4 64 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x34.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1320.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x34.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
@@ -11,15 +11,15 @@ class FakeAirPodsPro2Test : BaseAirPodsTest() {
@Test
fun `guessed data`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 58 35 00 10 00 E4 E4 26 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x5.toUShort()
rawCaseBattery shouldBe 0x8.toUShort()
rawCaseLidState shouldBe 0x35.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x5.toUShort()
pubCaseBattery shouldBe 0x8.toUShort()
pubCaseLidState shouldBe 0x35.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -38,15 +38,15 @@ class FakeAirPodsPro2Test : BaseAirPodsTest() {
@Test
fun `user supplied`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 72 39 00 00 6F E4 E4 93 30 00 30 30 30 30") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x7.toUShort()
rawCaseBattery shouldBe 0x2.toUShort()
rawCaseLidState shouldBe 0x39.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x0.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x7.toUShort()
pubCaseBattery shouldBe 0x2.toUShort()
pubCaseLidState shouldBe 0x39.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x0.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -11,15 +11,15 @@ class FakeAirPodsProTest : BaseAirPodsTest() {
@Test
fun `guessed data`() = runTest {
create<FakeAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0E20.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x36.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0E20.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x36.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f