app/src/main/java/dev/nathan/airpodstv/BeaconParser.kt
package dev.nathan.airpodstv
import android.bluetooth.le.ScanResult
/**
* Parses Apple "proximity pairing" BLE advertisements (manufacturer 0x004C, type 0x07),
* which AirPods broadcast while the case lid is open or the pods are out of the case.
*
* Byte layout of the manufacturer-specific data (company ID already stripped by Android):
* [0] message type (0x07) [1] payload length (0x19 = 25)
* [2] prefix [3..4] device model (big endian)
* [5] status [6] pod batteries (nibbles)
* [7] flags (hi) / case battery (lo)
* [8] lid state [9] color [10] 0x00 [11..26] encrypted
*/
object BeaconParser {
const val APPLE_COMPANY_ID = 0x004C
private const val PROXIMITY_FRAME_SIZE = 27
private const val PROXIMITY_MESSAGE_TYPE = 0x07
private const val PROXIMITY_PAYLOAD_LENGTH = 0x19
private const val PROXIMITY_PREFIX = 0x01
enum class LidState { OPEN, CLOSED, UNKNOWN }
data class Beacon(
val address: String,
val rssi: Int,
val model: Int,
val lidState: LidState,
val podsInCase: Boolean,
val bothPodsInCase: Boolean = false,
val statusByte: Int = 0,
val lidByte: Int = 0,
val leftBattery: Int?,
val rightBattery: Int?,
val caseBattery: Int?,
val leftCharging: Boolean,
val rightCharging: Boolean,
val caseCharging: Boolean,
val leftInEar: Boolean = false,
val rightInEar: Boolean = false,
val timestamp: Long = System.currentTimeMillis(),
) {
val modelName: String get() = MODEL_NAMES[model] ?: "AirPods (0x%04X)".format(model)
}
private val MODEL_NAMES = mapOf(
0x0220 to "AirPods",
0x0F20 to "AirPods 2",
0x1320 to "AirPods 3",
0x1920 to "AirPods 4",
0x1B20 to "AirPods 4 (ANC)",
0x0E20 to "AirPods Pro",
0x1420 to "AirPods Pro 2",
0x2420 to "AirPods Pro 2 (USB-C)",
0x0A20 to "AirPods Max",
0x1F20 to "AirPods Max (USB-C)",
0x0320 to "Powerbeats 3",
0x0520 to "Beats X",
0x0620 to "Beats Solo 3",
)
internal data class CaseState(
val thisPodInCase: Boolean,
val podsInCase: Boolean,
val bothPodsInCase: Boolean,
val lidState: LidState,
)
internal fun decodeCaseState(status: Int, lidByte: Int): CaseState {
val thisPodInCase = (status and 0x40) != 0
val onePodInCase = (status and 0x10) != 0
val bothInCase = (status and 0x04) != 0
val anyPodInCase = thisPodInCase || onePodInCase || bothInCase
val broadcastingPodInCase = thisPodInCase || bothInCase
// The lid bit is trustworthy only from a pod broadcasting inside the case,
// or while both pods are in it. A bit-4-only frame comes from the pod outside
// the case and can carry a stale lid byte.
val lidReadingReliable = thisPodInCase || bothInCase
val lidState = when {
!anyPodInCase -> LidState.UNKNOWN
!lidReadingReliable -> LidState.UNKNOWN
(lidByte shr 3) and 1 == 0 -> LidState.OPEN
else -> LidState.CLOSED
}
return CaseState(thisPodInCase, broadcastingPodInCase, bothInCase, lidState)
}
fun parse(result: ScanResult): Beacon? {
val data = result.scanRecord?.getManufacturerSpecificData(APPLE_COMPANY_ID) ?: return null
return parseManufacturerData(
data = data,
address = result.device?.address ?: "?",
rssi = result.rssi,
)
}
internal fun parseManufacturerData(
data: ByteArray,
address: String,
rssi: Int,
timestamp: Long = System.currentTimeMillis(),
): Beacon? {
// Type 0x07 is also used by shorter identity-address frames whose bytes do not
// follow this layout. Accept only the complete proximity-pairing payload.
if (data.size != PROXIMITY_FRAME_SIZE) return null
if (data[0].toInt() and 0xFF != PROXIMITY_MESSAGE_TYPE) return null
if (data[1].toInt() and 0xFF != PROXIMITY_PAYLOAD_LENGTH) return null
if (data[2].toInt() and 0xFF != PROXIMITY_PREFIX) return null
val model = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
val status = data[5].toInt() and 0xFF
val podsBattery = data[6].toInt() and 0xFF
val flagsCase = data[7].toInt() and 0xFF
val lidByte = data[8].toInt() and 0xFF
// Status bit 5 set = left pod is primary; unset = values are flipped.
val flipped = (status and 0x20) == 0
val caseState = decodeCaseState(status, lidByte)
val thisPodInCase = caseState.thisPodInCase
val podsInCase = caseState.podsInCase
// 15 = unknown; 11..14 are anomalous frames (CAPod logs these as ">100%"),
// safer to drop them than to show a phantom 100%.
fun nibbleToPercent(v: Int): Int? = when {
v in 0..10 -> v * 10
else -> null
}
val upper = (podsBattery shr 4) and 0xF
val lower = podsBattery and 0xF
val left = nibbleToPercent(if (flipped) upper else lower)
val right = nibbleToPercent(if (flipped) lower else upper)
val flags = (flagsCase shr 4) and 0xF
val caseBattery = nibbleToPercent(flagsCase and 0xF)
val leftCharging = (flags and (if (flipped) 0x2 else 0x1)) != 0
val rightCharging = (flags and (if (flipped) 0x1 else 0x2)) != 0
val caseCharging = (flags and 0x4) != 0
// In-ear bits swap depending on flip AND on whether the broadcaster is in the case
// (CAPod: areValuesFlipped xor isThisPodInThecase).
val earFlip = flipped xor thisPodInCase
val leftInEar = (status and (if (earFlip) 0x08 else 0x02)) != 0
val rightInEar = (status and (if (earFlip) 0x02 else 0x08)) != 0
return Beacon(
address = address,
rssi = rssi,
model = model,
lidState = caseState.lidState,
podsInCase = podsInCase,
bothPodsInCase = caseState.bothPodsInCase,
statusByte = status,
lidByte = lidByte,
leftBattery = left,
rightBattery = right,
caseBattery = caseBattery,
leftCharging = leftCharging,
rightCharging = rightCharging,
caseCharging = caseCharging,
leftInEar = leftInEar,
rightInEar = rightInEar,
timestamp = timestamp,
)
}
}