feat(aap): Build a comprehensive AAP protocol catalog

Adopts the Wireshark AAP dissector (pabloaul/apple-wireshark) as a third reference source alongside LibrePods and MagicPodsCore. Catalogues every known message type and control/setting ID, corrects DeviceInfo field labels, and adds sealed AapPacket hierarchy with Connect Response parsing. Case Info probe (Pro 3), Sleep event, and Dynamic End of Charge decoders are in place for future use.
This commit is contained in:
darken
2026-04-24 08:39:46 +02:00
committed by Matthias Urhahn
parent 118eaa8d08
commit a701fdfba7
37 changed files with 1895 additions and 286 deletions
@@ -0,0 +1,74 @@
package eu.darken.capod.monitor.core.cache
import eu.darken.capod.pods.core.apple.PodModel
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Guards the cache schema against accidental breakage when AAP-layer fields are
* renamed. The wire key `buildNumber` was renamed to `marketingVersion` in the
* domain type; the cache keeps the original key via `@SerialName("buildNumber")`
* so devices that upgrade from older CAPod versions don't drop their cached data.
*/
class CachedDeviceStateMigrationTest : BaseTest() {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `pre-rename JSON with buildNumber key loads into marketingVersion field`() {
val legacy = """
{
"profileId": "legacy-profile",
"model": "airpods.pro3",
"address": "AA:BB:CC:DD:EE:FF",
"deviceName": "AirPods Pro 3",
"serialNumber": "W5J7KV0N04",
"firmwareVersion": "81.26750000075000000.6503",
"leftEarbudSerial": "GMPHNZ16P5Z0000UHZ",
"rightEarbudSerial": "GMVHNX15UED0000UHY",
"buildNumber": "8454624",
"lastSeenAt": 1767364074000
}
""".trimIndent()
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
state.marketingVersion shouldBe "8454624"
state.profileId shouldBe "legacy-profile"
state.model shouldBe PodModel.AIRPODS_PRO3
state.firmwareVersion shouldBe "81.26750000075000000.6503"
}
@Test
fun `round-trip serializes marketingVersion back to buildNumber key`() {
val state = CachedDeviceState(
profileId = "roundtrip",
model = PodModel.AIRPODS_PRO3,
marketingVersion = "8454624",
lastSeenAt = java.time.Instant.ofEpochMilli(1767364074000L),
)
val encoded = json.encodeToString(CachedDeviceState.serializer(), state)
// Cache back-compat: the JSON key is still "buildNumber", never the in-memory name
(encoded.contains("\"buildNumber\":\"8454624\"")) shouldBe true
(encoded.contains("\"marketingVersion\"")) shouldBe false
}
@Test
fun `deviceInfo surfaces marketingVersion from legacy cached state`() {
val legacy = """
{
"profileId": "legacy-profile",
"model": "airpods.pro3",
"deviceName": "AirPods",
"serialNumber": "ABC",
"firmwareVersion": "81.x",
"buildNumber": "8454480",
"lastSeenAt": 1697480211000
}
""".trimIndent()
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
state.deviceInfo!!.marketingVersion shouldBe "8454480"
}
}
@@ -119,7 +119,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
// ── In-Case Tone (0x31) ─────────────────────────────────
// Decode path is kept internally even though the setting is no longer exposed in the UI.
// See DefaultAapDeviceProfile.SETTING_IN_CASE_TONE for rationale.
// See the IN_CASE_TONE branch in DefaultAapDeviceProfile.decodeSetting for rationale.
@Nested
inner class InCaseToneTests {
@@ -25,11 +25,15 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
// ── Handshake ────────────────────────────────────────────
@Test
fun `handshake response - 18 bytes`() {
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
msg.commandType shouldBe 0x0000
msg.raw.size shouldBe 18
msg.payload.size shouldBe 12
fun `handshake response - 18 bytes parses as ConnectResponse status=0 major=1 minor=3`() {
val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse)
packet.service shouldBe 0x0004
packet.status shouldBe 0x0000
packet.major shouldBe 0x0001
packet.minor shouldBe 0x0003
packet.features shouldBe 0UL
packet.raw.size shouldBe 18
}
// ── Device Info ──────────────────────────────────────────
@@ -58,7 +62,15 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "H3KL7HR926JY"
info.rightEarbudSerial shouldBe "H3KL2AYL26K0"
info.buildNumber shouldBe "8454480"
info.marketingVersion shouldBe "8454480"
info.hardwareVersion shouldBe "1.0.0"
info.eaProtocolName shouldBe "com.apple.accessory.updater.app.71"
info.firmwareVersion shouldBe "81.2675000075000000.6082"
info.firmwareVersionPending shouldBe null
info.leftEarbudUuid!!.size shouldBe 17
info.rightEarbudUuid!!.size shouldBe 17
info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L)
info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L)
}
// ── Battery ──────────────────────────────────────────────
@@ -25,11 +25,15 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
// ── Handshake ────────────────────────────────────────────
@Test
fun `handshake response - 18 bytes`() {
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
msg.commandType shouldBe 0x0000
msg.raw.size shouldBe 18
msg.payload.size shouldBe 12
fun `handshake response - 18 bytes parses as ConnectResponse status=0 major=1 minor=3`() {
val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse)
packet.service shouldBe 0x0004
packet.status shouldBe 0x0000
packet.major shouldBe 0x0001
packet.minor shouldBe 0x0003
packet.features shouldBe 0UL
packet.raw.size shouldBe 18
}
// ── Device Info ──────────────────────────────────────────
@@ -58,7 +62,15 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ"
info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY"
info.buildNumber shouldBe "8454624"
info.marketingVersion shouldBe "8454624"
info.hardwareVersion shouldBe "1.0.0"
info.eaProtocolName shouldBe "com.apple.accessory.updater.app.71"
info.firmwareVersion shouldBe "81.2675000075000000.6503"
info.firmwareVersionPending shouldBe null
info.leftEarbudUuid!!.size shouldBe 17
info.rightEarbudUuid!!.size shouldBe 17
info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1767364074L)
info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1767364074L)
}
// ── Battery ──────────────────────────────────────────────
@@ -25,11 +25,19 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
// ── Handshake ────────────────────────────────────────────
@Test
fun `handshake response - 18 bytes`() {
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2")
msg.commandType shouldBe 0x0000
msg.raw.size shouldBe 18
msg.payload.size shouldBe 12
fun `handshake response - 18 bytes parses as ConnectResponse with non-zero features bitmask`() {
// Pro 1 (firmware 51.9.6) reports actual bits in the 64-bit features field.
// Pro 2 USB-C and Pro 3 both report all zeroes. Useful for future
// bit-to-feature correlation work.
val packet = aapPacket("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2")
check(packet is eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.ConnectResponse)
packet.service shouldBe 0x0004
packet.status shouldBe 0x0000
packet.major shouldBe 0x0001
packet.minor shouldBe 0x0003
// Little-endian 8-byte features: 04 00 B1 E1 04 00 51 E2
packet.features shouldBe 0xE251_0004_E1B1_0004UL
packet.raw.size shouldBe 18
}
// ── Device Info ──────────────────────────────────────────
@@ -58,7 +66,16 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GXDDRFNW0C6K"
info.rightEarbudSerial shouldBe "H6RHL0HF0C6J"
info.buildNumber shouldBe "3344646"
info.marketingVersion shouldBe "3344646"
info.hardwareVersion shouldBe "1.0.0"
info.eaProtocolName shouldBe "com.apple.accessory.updater.app.60-ANC"
info.firmwareVersion shouldBe "51.9.6"
// Active firmware matches pending firmware verbatim → pending should be null
info.firmwareVersionPending shouldBe null
info.leftEarbudUuid!!.size shouldBe 17
info.rightEarbudUuid!!.size shouldBe 17
info.leftEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1637166475L)
info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1651135834L)
}
// ── Battery ──────────────────────────────────────────────
@@ -4,7 +4,6 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -63,13 +62,28 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// Every well-formed text segment retains a hex rendering too.
segments[0].hex shouldBe "416972506F64732050726F"
// The encrypted blob immediately follows "8454480" without a NUL separator, so it merges
// with the subsequent manufacturing-date string into a single non-UTF-8 chunk. The helper
// surfaces it as a segment with utf8 == null and a hex rendering that still contains the
// original blob bytes so maintainers can spot it in a debug recording.
val nonUtf8 = segments.firstOrNull { it.utf8 == null }
nonUtf8.shouldNotBeNull()
nonUtf8.hex.contains("1F3FB4B7E98148") shouldBe true
// Segments 11 and 12 are fixed 17-byte UUIDs per the dissector schema, not
// NUL-delimited. Verify the segmenter knows this and keeps the right offsets
// for everything that follows (timestamps at 13 and 14).
segments[5].utf8 shouldBe "81.2675000075000000.6082" // firmwareVersionPending
segments[6].utf8 shouldBe "1.0.0" // hardwareVersion
segments[7].utf8 shouldBe "com.apple.accessory.updater.app.71"
segments[8].utf8 shouldBe "H3KL7HR926JY"
segments[9].utf8 shouldBe "H3KL2AYL26K0"
segments[10].utf8 shouldBe "8454480"
segments[11].length shouldBe 17
segments[11].hex shouldBe "1F3FB4B7E9814811946BC26F3C5F5A340B"
segments[11].utf8.shouldBeNull() // binary UUID — not decodable as UTF-8
segments[12].length shouldBe 17
segments[12].hex shouldBe "AB7E42AABDF149E3A898E781D604F5681F"
segments[12].utf8.shouldBeNull()
segments[13].utf8 shouldBe "1697480211"
segments[14].utf8 shouldBe "1697480211"
segments.size shouldBe 15
}
@Test
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty
@@ -50,11 +51,15 @@ class AapSessionEngineTest : BaseTest() {
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeCaseInfo(any()) } returns null
every { decodeSleepEvent(any()) } returns null
every { decodeDynamicEndOfChargeEvent(any()) } returns null
every { decodeSetting(any()) } returns null
every { encodeHandshake() } returns ByteArray(10)
every { encodeNotificationEnable() } returns emptyList()
every { encodeInitExt() } returns ByteArray(10)
every { encodePrivateKeyRequest() } returns null
every { encodeCaseInfoRequest() } returns null
block()
}
@@ -188,6 +193,52 @@ class AapSessionEngineTest : BaseTest() {
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
}
@Test
fun `Connect Response with status=0 stores features bitmask`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
val packet = AapPacket.ConnectResponse(
raw = ByteArray(18),
service = 0x0004,
status = 0x0000,
major = 0x0001,
minor = 0x0003,
features = 0xE251_0004_E1B1_0004UL,
)
engine.processConnectResponse(packet)
engine.state.value.negotiatedFeatures shouldBe 0xE251_0004_E1B1_0004UL
engine.state.value.connectResponseStatus shouldBe 0
engine.state.value.lastMessageAt.shouldNotBeNull()
}
@Test
fun `Connect Response with non-zero status does not store features`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
val packet = AapPacket.ConnectResponse(
raw = ByteArray(18),
service = 0x0004,
status = 0x0001,
major = 0x0001,
minor = 0x0003,
features = 0xFFFFFFFFFFFFFFFFUL,
)
engine.processConnectResponse(packet)
engine.state.value.connectResponseStatus shouldBe 0x0001
engine.state.value.negotiatedFeatures.shouldBeNull()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
}
}
// ── Send path ───────────────────────────────────────────
@@ -77,6 +77,50 @@ class HidTrackerTest : BaseTest() {
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
@Test
fun `service info frame extracts ASCII tokens (VendorID, SerialNumber, CFG…)`() {
// Excerpt from a real AirPods Pro 2 USB-C 0x0017 frame observed after
// the descriptor batch. Magic 00 00 10 00 + TLV-ish mix of ASCII keys
// and binary values.
val payload = hexToBytes(
"00 00 10 00 D4 01 08 01 10 03 2A CD 03 08 10 12 C8 03 D3 00 00 00 " +
"0C 00 00 81 08 00 00 09 56 65 6E 64 6F 72 49 44 40 00 00 04 AC 05 00 00 " +
"00 00 00 00 0C 00 00 09 53 65 72 69 61 6C 4E 75 6D 62 65 72 0C 00 00 09 " +
"48 33 4B 4C 37 48 52 39 32 36 4A 59 04 00 00 09 43 46 47"
)
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceInfo>()
// 0x40 ('@') is printable ASCII and sits right after "VendorID" as a TLV
// tag byte — the extractor includes any adjacent printable byte, so the
// token surfaces as "VendorID@". That's an acceptable cosmetic side-effect
// for a debug log; the key string stays grep-able.
result.asciiTokens shouldContainExactly listOf(
"VendorID@",
"SerialNumber",
"H3KL7HR926JY",
"CFG",
)
result.payloadSize shouldBe payload.size
}
@Test
fun `service info frame with no ASCII runs returns empty token list`() {
val payload = hexToBytes("00 00 10 00 01 02 03 04 05 06 07 08")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceInfo>()
result.asciiTokens.shouldBeEmpty()
}
@Test
fun `service info magic requires exact 00 00 10 00 prefix`() {
// 00 04 00 00 (descriptor prefix) must not match ServiceInfo
HidTracker.classify(hexToBytes("00 04 00 00 44 00 01 A1 81") + ByteArray(65) { 0xFF.toByte() })
.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
// 00 00 10 01 must not match
HidTracker.classify(hexToBytes("00 00 10 01 AA BB CC DD"))
.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
}
// ── Batching ───────────────────────────────────────────
@@ -184,6 +228,25 @@ class HidTrackerTest : BaseTest() {
logs[1] shouldBe "HID: terminator (7B)"
}
@Test
fun `service info flushes pending batch and logs named tokens`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val serviceInfo = hexToBytes(
"00 00 10 00 00 00 00 00 56 65 6E 64 6F 72 49 44 00 00"
)
tracker.consume(descriptor)
tracker.consume(serviceInfo)
logs.size shouldBe 2
logs[0] shouldBe "HID: 1 descriptor frames phase=0x81 fill=0xFF"
logs[1] shouldBe "HID: service info tokens=[VendorID] (${serviceInfo.size}B)"
}
@Test
fun `flush with zero count is no-op`() {
val logs = mutableListOf<String>()
@@ -0,0 +1,53 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AapControlIdTest : BaseTest() {
@Test
fun `no duplicate control IDs`() {
val grouped = AapControlId.entries.groupBy { it.value }
val duplicates = grouped.filterValues { it.size > 1 }
duplicates shouldBe emptyMap()
}
@Test
fun `every entry round-trips via byValue`() {
for (id in AapControlId.entries) {
AapControlId.byValue(id.value) shouldBe id
}
}
@Test
fun `unknown control IDs return null`() {
AapControlId.byValue(0x7F).shouldBeNull()
AapControlId.byValue(-1).shouldBeNull()
AapControlId.byValue(0x50).shouldBeNull() // Past the known catalog
}
@Test
fun `load-bearing control IDs keep their values`() {
AapControlId.MIC_MODE.value shouldBe 0x01
AapControlId.IN_EAR_DETECTION.value shouldBe 0x0A
AapControlId.LISTEN_MODE.value shouldBe 0x0D
AapControlId.DOUBLE_CLICK_INTERVAL.value shouldBe 0x17
AapControlId.CLICK_AND_HOLD_INTERVAL.value shouldBe 0x18
AapControlId.LISTENING_MODE_CONFIGS.value shouldBe 0x1A
AapControlId.ONE_BUD_ANC_MODE.value shouldBe 0x1B
AapControlId.CHIME_VOLUME.value shouldBe 0x1F
AapControlId.VOLUME_SWIPE_INTERVAL.value shouldBe 0x23
AapControlId.CALL_MANAGEMENT_CONFIG.value shouldBe 0x24
AapControlId.VOLUME_SWIPE_MODE.value shouldBe 0x25
AapControlId.ADAPTIVE_VOLUME.value shouldBe 0x26
AapControlId.CONVERSATION_DETECT.value shouldBe 0x28
AapControlId.AUTO_ANC_STRENGTH.value shouldBe 0x2E
AapControlId.IN_CASE_TONE.value shouldBe 0x31
AapControlId.ALLOW_OFF_OPTION.value shouldBe 0x34
AapControlId.SLEEP_DETECTION.value shouldBe 0x35
AapControlId.RAW_GESTURES_CONFIG.value shouldBe 0x39
AapControlId.DYNAMIC_END_OF_CHARGE.value shouldBe 0x3B
}
}
@@ -21,14 +21,16 @@ class AapMessageTest : BaseTest() {
}
@Test
fun `parse handshake response`() {
fun `handshake response parses via AapMessage-parse as null (not a Message packet)`() {
// Packet type 0x0001 = Connect Response. AapMessage.parse now only returns
// Message-type packets; the full packet lives in AapPacket.parse.
// Historically this test asserted it decoded as a Message with commandType=0 —
// that was treating the Connect Response's `status` field as a command ID.
val raw = byteArrayOf(
0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00,
0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
)
val msg = AapMessage.parse(raw)
msg.shouldNotBeNull()
msg.commandType shouldBe 0x0000
AapMessage.parse(raw).shouldBeNull()
}
@Test
@@ -0,0 +1,49 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AapMessageTypeTest : BaseTest() {
@Test
fun `no duplicate opcodes`() {
val grouped = AapMessageType.entries.groupBy { it.value }
val duplicates = grouped.filterValues { it.size > 1 }
duplicates shouldBe emptyMap()
}
@Test
fun `every entry round-trips via byValue`() {
for (type in AapMessageType.entries) {
AapMessageType.byValue(type.value) shouldBe type
}
}
@Test
fun `unknown opcodes return null`() {
AapMessageType.byValue(0x7FFF).shouldBeNull()
AapMessageType.byValue(-1).shouldBeNull()
AapMessageType.byValue(0x0018).shouldBeNull() // Gap in the known catalog
}
@Test
fun `load-bearing opcodes keep their values`() {
AapMessageType.BATTERY_INFO.value shouldBe 0x0004
AapMessageType.EAR_DETECTION.value shouldBe 0x0006
AapMessageType.BUD_ROLE.value shouldBe 0x0008
AapMessageType.CONTROL.value shouldBe 0x0009
AapMessageType.AUDIO_SOURCE.value shouldBe 0x000E
AapMessageType.BUDDY_COMMAND.value shouldBe 0x0017
AapMessageType.STEM_PRESS.value shouldBe 0x0019
AapMessageType.RENAME.value shouldBe 0x001A
AapMessageType.INFORMATION.value shouldBe 0x001D
AapMessageType.CONNECTED_DEVICES.value shouldBe 0x002E
AapMessageType.MAGIC_KEYS_REQUEST.value shouldBe 0x0030
AapMessageType.MAGIC_KEYS.value shouldBe 0x0031
AapMessageType.CONVERSATIONAL_AWARENESS.value shouldBe 0x004B
AapMessageType.SOURCE_FEATURE_CAPABILITIES.value shouldBe 0x004D
AapMessageType.PME_CONFIG.value shouldBe 0x0053
}
}
@@ -0,0 +1,113 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AapPacketTest : BaseTest() {
private fun hex(str: String): ByteArray = str.split(" ")
.filter { it.isNotBlank() }
.map { it.toInt(16).toByte() }
.toByteArray()
@Test
fun `parse Connect (type 0x0000)`() {
// CAPod's handshake packet
val bytes = hex("00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.Connect>()
packet.service shouldBe 0x0004
packet.major shouldBe 0x0001
packet.minor shouldBe 0x0002
packet.features shouldBe 0UL
}
@Test
fun `parse Connect Response (type 0x0001) status=0 zero features`() {
val bytes = hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.ConnectResponse>()
packet.service shouldBe 0x0004
packet.status shouldBe 0x0000
packet.major shouldBe 0x0001
packet.minor shouldBe 0x0003
packet.features shouldBe 0UL
}
@Test
fun `parse Connect Response with non-zero features (Pro 1 capture)`() {
val bytes = hex("01 00 04 00 00 00 01 00 03 00 04 00 B1 E1 04 00 51 E2")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.ConnectResponse>()
packet.status shouldBe 0
packet.features shouldBe 0xE251_0004_E1B1_0004UL
}
@Test
fun `parse Connect Response with non-zero status does not crash`() {
// Simulate a failure response — status=1, everything else zeroed
val bytes = hex("01 00 04 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.ConnectResponse>()
packet.status shouldBe 0x0001
}
@Test
fun `parse Disconnect (type 0x0002)`() {
val bytes = hex("02 00 04 00 00 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.Disconnect>()
packet.service shouldBe 0x0004
packet.status shouldBe 0x0000
}
@Test
fun `parse Disconnect Response (type 0x0003)`() {
val bytes = hex("03 00 04 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.DisconnectResponse>()
packet.service shouldBe 0x0004
}
@Test
fun `parse Message (type 0x0004)`() {
// ANC mode ON control message
val bytes = hex("04 00 04 00 09 00 0D 02 00 00 00")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.Message>()
packet.commandType shouldBe 0x0009
packet.payload.size shouldBe 5
packet.payload[0] shouldBe 0x0D.toByte()
}
@Test
fun `parse unknown packet type returns Unknown variant`() {
val bytes = hex("09 00 04 00 AA BB CC")
val packet = AapPacket.parse(bytes).shouldBeInstanceOf<AapPacket.Unknown>()
packet.packetType shouldBe 0x0009
}
@Test
fun `parse returns null for too-short input`() {
AapPacket.parse(byteArrayOf()).shouldBeNull()
AapPacket.parse(hex("04 00")).shouldBeNull()
// Message packet minimum is 6 bytes (4-byte header + 2-byte command type)
AapPacket.parse(hex("04 00 04 00 09")).shouldBeNull()
}
@Test
fun `parse truncated Connect Response returns null`() {
// Need 18 bytes for a Connect Response; this is only 16
AapPacket.parse(hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00")).shouldBeNull()
}
@Test
fun `AapMessage-parse only returns Message-type packets`() {
// Connect Response bytes — AapMessage.parse (= AapPacket.Message.parse) rejects
val connectResponseBytes = hex("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
AapMessage.parse(connectResponseBytes).shouldBeNull()
// Message bytes — accepted
val messageBytes = hex("04 00 04 00 09 00 0D 02 00 00 00")
val msg = AapMessage.parse(messageBytes)
msg.shouldNotBeNull()
msg.commandType shouldBe 0x0009
}
}
@@ -18,14 +18,29 @@ abstract class BaseAapSessionTest : BaseTest() {
// ── Hex parsing ──────────────────────────────────────────
/** Parse hex string(s) into an [AapMessage]. Multiple args are concatenated. */
protected fun aapMessage(vararg hexParts: String): AapMessage {
val bytes = hexParts.joinToString(" ")
/** Concatenate hex string parts into a byte array. */
protected fun parseHex(vararg hexParts: String): ByteArray =
hexParts.joinToString(" ")
.split(" ")
.filter { it.isNotBlank() }
.map { it.toInt(16).toByte() }
.toByteArray()
return AapMessage.parse(bytes) ?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}")
/** Parse hex string(s) into an [AapMessage]. Fails if the bytes aren't a Message-type packet. */
protected fun aapMessage(vararg hexParts: String): AapMessage {
val bytes = parseHex(*hexParts)
return AapMessage.parse(bytes)
?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}")
}
/**
* Parse hex string(s) into an [AapPacket] — use this for Connect / Connect Response
* / Disconnect frames where [aapMessage] would return null.
*/
protected fun aapPacket(vararg hexParts: String): AapPacket {
val bytes = parseHex(*hexParts)
return AapPacket.parse(bytes)
?: error("Failed to parse AapPacket from: ${hexParts.joinToString(" ")}")
}
// ── Message builders (hide protocol header bytes) ────────
@@ -0,0 +1,162 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import eu.darken.capod.pods.core.apple.PodModel
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
/**
* Edge-case coverage for `DefaultAapDeviceProfile.decodeDeviceInfo`. The three
* `AirPods*AapSessionTest` classes hold the authoritative per-model golden
* captures; this file covers the parser's behaviour when payloads deviate
* from the full 15-segment schema.
*/
class DefaultAapDeviceProfileDeviceInfoTest : BaseAapSessionTest() {
override val podModel = PodModel.AIRPODS_PRO3
private fun infoMessage(vararg hexParts: String): AapMessage =
aapMessage("04 00 04 00 1D 00 02 ED 00 04 00", *hexParts)
@Test
fun `truncated payload with only required system fields decodes without crashing`() {
val msg = infoMessage(
"41 69 72 50 6F 64 73 00", // "AirPods\0"
"41 32 30 38 34 00", // "A2084\0"
"41 70 70 6C 65 00", // "Apple\0"
"53 31 32 33 00", // "S123\0"
)
val info = profile.decodeDeviceInfo(msg)!!
info.name shouldBe "AirPods"
info.modelNumber shouldBe "A2084"
info.manufacturer shouldBe "Apple"
info.serialNumber shouldBe "S123"
info.firmwareVersion shouldBe ""
info.firmwareVersionPending.shouldBeNull()
info.marketingVersion.shouldBeNull()
info.leftEarbudUuid.shouldBeNull()
info.rightEarbudUuid.shouldBeNull()
info.leftEarbudFirstPaired.shouldBeNull()
info.rightEarbudFirstPaired.shouldBeNull()
}
@Test
fun `truncated after marketingVersion leaves UUIDs and timestamps null`() {
val msg = infoMessage(
"41 69 72 50 6F 64 73 00",
"41 32 30 38 34 00",
"41 70 70 6C 65 00",
"53 31 32 33 00",
"38 31 00", // firmware active "81\0"
"38 31 00", // firmware pending (same) → should be null
"31 2E 30 2E 30 00", // hardware "1.0.0\0"
"65 61 00", // EA protocol "ea\0"
"4C 31 00", // left bud serial "L1\0"
"52 31 00", // right bud serial "R1\0"
"38 34 35 34 00", // marketing version "8454\0"
// No UUID blob, no timestamps
)
val info = profile.decodeDeviceInfo(msg)!!
info.marketingVersion shouldBe "8454"
info.firmwareVersion shouldBe "81"
info.firmwareVersionPending.shouldBeNull()
info.hardwareVersion shouldBe "1.0.0"
info.eaProtocolName shouldBe "ea"
info.leftEarbudSerial shouldBe "L1"
info.rightEarbudSerial shouldBe "R1"
info.leftEarbudUuid.shouldBeNull()
info.rightEarbudUuid.shouldBeNull()
info.leftEarbudFirstPaired.shouldBeNull()
info.rightEarbudFirstPaired.shouldBeNull()
}
@Test
fun `partial UUID present - single left UUID but no right - does not fail`() {
val leftUuidHex = "AA BB CC DD EE FF 11 22 33 44 55 66 77 88 99 00 01"
val msg = infoMessage(
"41 00", "41 00", "41 00", "53 00",
"46 00", "46 00", "48 00", "65 00",
"4C 00", "52 00", "4D 00",
leftUuidHex,
// 17 bytes — enough for left UUID, right UUID read fails
)
val info = profile.decodeDeviceInfo(msg)!!
info.leftEarbudUuid!!.size shouldBe 17
info.leftEarbudUuid!![0] shouldBe 0xAA.toByte()
info.leftEarbudUuid!![16] shouldBe 0x01.toByte()
info.rightEarbudUuid.shouldBeNull()
}
@Test
fun `firmware pending decoded when different from active firmware`() {
val msg = infoMessage(
"41 00", "41 00", "41 00", "53 00",
"38 31 2E 31 00", // firmware active "81.1\0"
"38 31 2E 32 00", // firmware pending "81.2\0" (different)
"31 2E 30 00",
)
val info = profile.decodeDeviceInfo(msg)!!
info.firmwareVersion shouldBe "81.1"
info.firmwareVersionPending shouldBe "81.2"
}
@Test
fun `firmware pending suppressed when matching active firmware`() {
val msg = infoMessage(
"41 00", "41 00", "41 00", "53 00",
"38 31 00",
"38 31 00",
)
val info = profile.decodeDeviceInfo(msg)!!
info.firmwareVersion shouldBe "81"
info.firmwareVersionPending.shouldBeNull()
}
@Test
fun `UUID with embedded zero bytes is preserved verbatim`() {
val uuidWithZero = "11 00 22 00 33 44 55 66 77 88 99 AA BB CC DD EE FF"
val msg = infoMessage(
"41 00", "41 00", "41 00", "53 00",
"46 00", "46 00", "48 00", "65 00",
"4C 00", "52 00", "4D 00",
uuidWithZero,
uuidWithZero,
)
val info = profile.decodeDeviceInfo(msg)!!
// Embedded 0x00 at indices 1 and 3 must be preserved — we must NOT split UUID on NUL
info.leftEarbudUuid!!.size shouldBe 17
info.leftEarbudUuid!![0] shouldBe 0x11.toByte()
info.leftEarbudUuid!![1] shouldBe 0x00.toByte()
info.leftEarbudUuid!![2] shouldBe 0x22.toByte()
info.leftEarbudUuid!![3] shouldBe 0x00.toByte()
info.rightEarbudUuid!![1] shouldBe 0x00.toByte()
}
@Test
fun `malformed first-paired timestamp is silently dropped`() {
val uuid = "11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF 00 01"
val msg = infoMessage(
"41 00", "41 00", "41 00", "53 00",
"46 00", "46 00", "48 00", "65 00",
"4C 00", "52 00", "4D 00",
uuid, uuid,
"6E 6F 74 61 64 61 74 65 00", // "notadate\0"
"31 36 39 37 34 38 30 32 31 31 00", // valid right timestamp
)
val info = profile.decodeDeviceInfo(msg)!!
info.leftEarbudFirstPaired.shouldBeNull()
info.rightEarbudFirstPaired shouldBe java.time.Instant.ofEpochSecond(1697480211L)
}
@Test
fun `decodeDeviceInfo returns null for non-Information messages`() {
val msg = aapMessage("04 00 04 00 09 00 0D 02 00 00 00") // Control message, not 0x1D
profile.decodeDeviceInfo(msg).shouldBeNull()
}
@Test
fun `decodeDeviceInfo returns null for too-short payload`() {
val msg = aapMessage("04 00 04 00 1D 00 02") // just 1 byte of payload after command type
profile.decodeDeviceInfo(msg).shouldBeNull()
}
}