mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
+74
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+18
-6
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
+18
-6
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
+23
-6
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
+22
-8
@@ -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
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -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) ────────
|
||||
|
||||
+162
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user