From a701fdfba7da85559688f11452648bac815d30e0 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 24 Apr 2026 08:28:00 +0200 Subject: [PATCH] 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. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 22 +- .../ui/devicesettings/cards/DeviceInfoCard.kt | 2 +- .../settings/acks/AcknowledgementsScreen.kt | 28 +- .../eu/darken/capod/monitor/core/PodDevice.kt | 2 +- .../monitor/core/cache/CachedDeviceState.kt | 9 +- .../core/cache/DeviceStateCacheExtensions.kt | 2 +- .../capod/pods/core/apple/aap/AapPodState.kt | 11 + .../core/apple/aap/engine/AapConnection.kt | 60 ++- .../aap/engine/AapDeviceInfoDiagnostics.kt | 99 +++-- .../apple/aap/engine/AapInboundInterpreter.kt | 11 + .../core/apple/aap/engine/AapSessionEngine.kt | 135 ++++++- .../pods/core/apple/aap/engine/HidTracker.kt | 54 +++ .../core/apple/aap/protocol/AapCaseInfo.kt | 54 +++ .../core/apple/aap/protocol/AapControlId.kt | 134 +++++++ .../core/apple/aap/protocol/AapDeviceInfo.kt | 87 ++++- .../apple/aap/protocol/AapDeviceProfile.kt | 27 ++ .../protocol/AapDynamicEndOfChargeEvent.kt | 20 + .../protocol/{AapMessage.kt => AapFramer.kt} | 37 +- .../core/apple/aap/protocol/AapMessageType.kt | 112 ++++++ .../pods/core/apple/aap/protocol/AapPacket.kt | 172 +++++++++ .../core/apple/aap/protocol/AapSetting.kt | 16 +- .../core/apple/aap/protocol/AapSleepEvent.kt | 20 + .../aap/protocol/DefaultAapDeviceProfile.kt | 360 +++++++++++------- .../cache/CachedDeviceStateMigrationTest.kt | 74 ++++ .../DefaultAapDeviceProfileNewSettingsTest.kt | 2 +- .../airpods/AirPodsPro2UsbcAapSessionTest.kt | 24 +- .../airpods/AirPodsPro3AapSessionTest.kt | 24 +- .../airpods/AirPodsProAapSessionTest.kt | 29 +- .../engine/AapDeviceInfoDiagnosticsTest.kt | 30 +- .../apple/aap/engine/AapSessionEngineTest.kt | 51 +++ .../core/apple/aap/engine/HidTrackerTest.kt | 63 +++ .../apple/aap/protocol/AapControlIdTest.kt | 53 +++ .../core/apple/aap/protocol/AapMessageTest.kt | 10 +- .../apple/aap/protocol/AapMessageTypeTest.kt | 49 +++ .../core/apple/aap/protocol/AapPacketTest.kt | 113 ++++++ .../apple/aap/protocol/BaseAapSessionTest.kt | 23 +- .../DefaultAapDeviceProfileDeviceInfoTest.kt | 162 ++++++++ 37 files changed, 1895 insertions(+), 286 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt rename app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/{AapMessage.kt => AapFramer.kt} (59%) create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index abd09540..5228d682 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -258,7 +258,7 @@ fun DeviceSettingsScreen( ) } val hasFirmware = info.firmwareVersion.isNotBlank() - val hasBuild = !info.buildNumber.isNullOrBlank() + val hasBuild = !info.marketingVersion.isNullOrBlank() if (hasFirmware && hasBuild) { add( DeviceDetailItem.Paired( @@ -268,7 +268,7 @@ fun DeviceSettingsScreen( ), end = DeviceDetailItem.Single( stringResource(R.string.device_settings_info_build_label), - info.buildNumber!! + info.marketingVersion!! ), ) ) @@ -283,7 +283,7 @@ fun DeviceSettingsScreen( add( DeviceDetailItem.Single( stringResource(R.string.device_settings_info_build_label), - info.buildNumber!! + info.marketingVersion!! ) ) } @@ -464,13 +464,21 @@ fun DeviceSettingsScreen( } } - // EQ visualization (debug only) - val eqBands = device.eqBands - if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) { + // PME Config visualization (debug only, opcode 0x53). + // The Wireshark dissector calls this "PME Config"; captures so far are + // all-zero on in-production firmware, so the bar chart stays hidden in + // that case — showing empty bars would imply the device has a flat EQ, + // which we don't actually know. + val pmeConfig = device.pmeConfig + if (eu.darken.capod.BuildConfig.DEBUG && + pmeConfig != null && + pmeConfig.sets.isNotEmpty() && + !pmeConfig.isAllZero + ) { item("eq_section") { SettingsSection(title = stringResource(R.string.device_settings_eq_label)) { EqBarsChart( - sets = eqBands.sets, + sets = pmeConfig.sets, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt index 7d9c2a24..92d17c2e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt @@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper { firmwareVersion = "7A305", leftEarbudSerial = "H3KL7HR926JY", rightEarbudSerial = "H3KL2AYL26K0", - buildNumber = "8454624", + marketingVersion = "8454624", ), modelLabel = "AirPods Pro 2 (A2699)", systemBluetoothName = "AirPods Pro", diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt index 53b23900..8505357d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt @@ -100,6 +100,13 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") }, ) } + item { + SettingsBaseItem( + title = "apple-wireshark", + subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.", + onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") }, + ) + } item { SettingsBaseItem( title = "crowdin.com", @@ -110,13 +117,6 @@ fun AcknowledgementsScreen( item { SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label)) } - item { - SettingsBaseItem( - title = "Glide", - subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)", - onClick = { onOpenUrl("https://github.com/bumptech/glide") }, - ) - } item { SettingsBaseItem( title = "Material Design Icons", @@ -145,6 +145,13 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") }, ) } + item { + SettingsBaseItem( + title = "apple-wireshark", + subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)", + onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") }, + ) + } item { SettingsBaseItem( title = "Kotlin", @@ -166,13 +173,6 @@ fun AcknowledgementsScreen( onClick = { onOpenUrl("https://source.android.com/source/licenses.html") }, ) } - item { - SettingsBaseItem( - title = "Android", - subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.", - onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") }, - ) - } } } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index c3bd0a35..bbd05fc7 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -343,7 +343,7 @@ data class PodDevice( val audioSource: AapSetting.AudioSource? get() = aap?.setting() - val eqBands: AapSetting.EqBands? + val pmeConfig: AapSetting.PmeConfig? get() = aap?.setting() val deviceInfo: AapDeviceInfo? diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt index 2ebc96b2..2c0d8e3e 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt @@ -25,7 +25,12 @@ data class CachedDeviceState( @SerialName("firmwareVersion") val firmwareVersion: String? = null, @SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null, @SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null, - @SerialName("buildNumber") val buildNumber: String? = null, + /** + * Formerly known as `buildNumber` — the Wireshark AAP dissector calls this + * "Marketing Version". The `@SerialName("buildNumber")` preserves the wire + * key so cache entries from older CAPod versions keep deserializing. + */ + @SerialName("buildNumber") val marketingVersion: String? = null, @Serializable(with = InstantEpochMillisSerializer::class) @SerialName("lastSeenAt") val lastSeenAt: Instant, ) { @@ -40,7 +45,7 @@ data class CachedDeviceState( firmwareVersion = firmwareVersion ?: "", leftEarbudSerial = leftEarbudSerial, rightEarbudSerial = rightEarbudSerial, - buildNumber = buildNumber, + marketingVersion = marketingVersion, ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt index 741bf62c..6e58f3c7 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -50,7 +50,7 @@ fun PodDevice.toCachedState( firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial, rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial, - buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber, + marketingVersion = liveDeviceInfo?.marketingVersion ?: existing?.marketingVersion, lastSeenAt = seenLastAt ?: now, ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index b6c52217..8aef76a0 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -1,5 +1,6 @@ package eu.darken.capod.pods.core.apple.aap +import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import java.time.Instant @@ -16,6 +17,16 @@ data class AapPodState( val lastMessageAt: Instant? = null, val pendingAncMode: AapSetting.AncMode.Value? = null, val pendingSettingsCount: Int = 0, + /** + * 64-bit features bitmask from the Connect Response (packet type 0x0001). + * Opaque — we don't yet know the bit-to-feature mapping. Logged for future + * correlation work. Null until a Connect Response is seen. + */ + val negotiatedFeatures: ULong? = null, + /** Status field from the Connect Response. 0 = success. Null before Connect Response. */ + val connectResponseStatus: Int? = null, + /** Parsed Case Info (message type 0x23), if the device responded to a 0x22 probe. */ + val caseInfo: AapCaseInfo? = null, ) { inline fun setting(): T? = settings[T::class] as? T diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index 37505004..ace5f5be 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -12,7 +12,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.AapFramer -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.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlinx.coroutines.CoroutineScope @@ -143,11 +143,36 @@ internal class AapConnection( break } - // L2CAP SEQPACKET: each read() returns exactly one complete message + // L2CAP SEQPACKET: each read() returns exactly one complete frame val raw = buf.copyOfRange(0, len) - val message = AapMessage.parse(raw) - if (message != null) { - engine.processMessage(message) + val packet = AapPacket.parse(raw) ?: continue + + when (packet) { + is AapPacket.Message -> engine.processMessage(packet) + is AapPacket.ConnectResponse -> { + engine.processConnectResponse(packet) + if (packet.status == 0) dispatchCaseInfoProbe() + } + is AapPacket.Disconnect -> { + log(TAG, Logging.Priority.INFO) { + "Disconnect received: service=0x${"%04X".format(packet.service)} status=0x${"%04X".format(packet.status)}" + } + } + is AapPacket.DisconnectResponse -> { + log(TAG) { "DisconnectResponse received: service=0x${"%04X".format(packet.service)}" } + } + is AapPacket.Connect -> { + // Unexpected — we're the source, not the peer. Log and ignore. + log(TAG, Logging.Priority.WARN) { + "Unexpected Connect packet from peer: service=0x${"%04X".format(packet.service)}" + } + } + is AapPacket.Unknown -> { + val hex = packet.raw.joinToString(" ") { "%02X".format(it) } + log(TAG, Logging.Priority.INFO) { + "Unknown AAP packet type=0x${"%04X".format(packet.packetType)} len=${packet.raw.size} raw=$hex" + } + } } } } catch (e: IOException) { @@ -158,6 +183,31 @@ internal class AapConnection( } } + /** + * Fire-and-forget Case Info request (message type 0x22). Sent after a + * successful Connect Response. Not an AapCommand — bypasses the outbound + * queue, ear-gating, and pending-settings counter. If the device doesn't + * reply, nothing happens; the next connect attempt re-probes. + */ + private suspend fun dispatchCaseInfoProbe() { + val bytes = profile.encodeCaseInfoRequest() ?: return + try { + writeMutex.withLock { + withContext(Dispatchers.IO) { + val sock = socket ?: return@withContext + sock.outputStream.write(bytes) + sock.outputStream.flush() + val hex = bytes.joinToString(" ") { "%02X".format(it) } + log(TAG, Logging.Priority.VERBOSE) { "SEND CaseInfoProbe len=${bytes.size} raw=$hex" } + } + } + } catch (e: Exception) { + // Non-critical: the probe is fire-and-forget; a send failure just means + // we don't get Case Info this session. No retry, no state change. + log(TAG, Logging.Priority.WARN) { "CaseInfoProbe send failed: $e" } + } + } + private fun cleanupSocket() { try { socket?.close() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt index dda1286c..2522ac59 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt @@ -4,47 +4,88 @@ import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction /** - * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for - * issue #173 engraving discovery logging. + * Diagnostic-only segmentation of a 0x1D INFORMATION payload, used for issue #173 + * engraving discovery logging. + * + * Schema matches the Wireshark AAP dissector's INFORMATION message: + * - Segments 0-10 are NUL-delimited UTF-8 strings. + * - Segments 11 and 12 are fixed 17-byte UUIDs (may contain 0x00 internally — + * splitting on NUL here would corrupt the offsets for every later segment). + * - Segments 13 and 14 are NUL-delimited ASCII-decimal timestamps. + * + * Payloads that truncate early just produce fewer segments — the helper stays + * best-effort and never throws. */ internal object AapDeviceInfoDiagnostics { + private const val UUID_LEN = 17 + private const val LAST_STRING_SEGMENT = 10 // after this the UUID blob starts + fun describeSegments(payload: ByteArray): List { - var start = 0 - while (start < payload.size) { - val b = payload[start].toInt() and 0xFF - if (b in 0x20..0x7E) break - start++ - } - if (start >= payload.size) return emptyList() + var offset = skipBinaryHeader(payload) + if (offset >= payload.size) return emptyList() val segments = mutableListOf() var segIndex = 0 - var i = start - while (i < payload.size) { - while (i < payload.size && payload[i] == 0x00.toByte()) i++ - if (i >= payload.size) break - val segStart = i - while (i < payload.size && payload[i] != 0x00.toByte()) i++ - val segBytes = payload.copyOfRange(segStart, i) - - val utf8: String? = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(segBytes)) - .toString() - } catch (_: CharacterCodingException) { - null - } - val hex = segBytes.joinToString("") { "%02X".format(it) } - - segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex) + // Segments 0..10 — NUL-delimited UTF-8 strings + while (segIndex <= LAST_STRING_SEGMENT && offset < payload.size) { + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + if (offset >= payload.size) break + val segStart = offset + while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++ + segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset)) segIndex++ } + + // Skip the NUL terminator of segment 10 before the UUID blob. + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + + // Segments 11 and 12 — fixed 17-byte UUIDs, read verbatim. + for (targetIdx in 11..12) { + if (offset + UUID_LEN > payload.size) break + val segBytes = payload.copyOfRange(offset, offset + UUID_LEN) + segments += buildSegment(targetIdx, offset, segBytes) + offset += UUID_LEN + segIndex = targetIdx + 1 + } + + // Segments 13+ — NUL-delimited timestamps / any trailing strings. + while (offset < payload.size) { + while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++ + if (offset >= payload.size) break + val segStart = offset + while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++ + segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset)) + segIndex++ + } + return segments } + + private fun skipBinaryHeader(payload: ByteArray): Int { + var i = 0 + while (i < payload.size) { + val b = payload[i].toInt() and 0xFF + if (b in 0x20..0x7E) return i + i++ + } + return payload.size + } + + private fun buildSegment(index: Int, offset: Int, bytes: ByteArray): DeviceInfoSegment { + val utf8: String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } catch (_: CharacterCodingException) { + null + } + val hex = bytes.joinToString("") { "%02X".format(it) } + return DeviceInfoSegment(index, offset, bytes.size, utf8, hex) + } } internal data class DeviceInfoSegment( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt index a8dd11aa..76a55666 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt @@ -1,10 +1,13 @@ package eu.darken.capod.pods.core.apple.aap.engine import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile +import eu.darken.capod.pods.core.apple.aap.protocol.AapDynamicEndOfChargeEvent import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import kotlin.reflect.KClass @@ -14,6 +17,9 @@ internal sealed interface AapInboundUpdate { data class Battery(val batteries: Map) : AapInboundUpdate data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate + data class CaseInfo(val info: AapCaseInfo) : AapInboundUpdate + data class SleepEvent(val event: AapSleepEvent) : AapInboundUpdate + data class DynamicEndOfChargeEvent(val event: AapDynamicEndOfChargeEvent) : AapInboundUpdate data class Setting(val key: KClass, val value: AapSetting) : AapInboundUpdate } @@ -25,6 +31,11 @@ internal class AapInboundInterpreter( profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) } profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) } profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) } + profile.decodeCaseInfo(message)?.let { return AapInboundUpdate.CaseInfo(it) } + profile.decodeSleepEvent(message)?.let { return AapInboundUpdate.SleepEvent(it) } + profile.decodeDynamicEndOfChargeEvent(message)?.let { + return AapInboundUpdate.DynamicEndOfChargeEvent(it) + } profile.decodeSetting(message)?.let { (key, value) -> return AapInboundUpdate.Setting(key, value) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index 6775b88b..9c67a2f2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -10,6 +10,8 @@ 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.AapMessageType +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.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent @@ -88,6 +90,21 @@ internal class AapSessionEngine( dispatch(AapEngineEvent.MessageReceived(message)) } + /** + * Handle a Connect Response (packet type 0x0001) from the peer. Stores + * the 64-bit features bitmask in [AapPodState.negotiatedFeatures] for + * future correlation work, but does NOT drive state transitions itself — + * the first subsequent Message packet triggers HANDSHAKING → READY via + * the existing logic. + * + * On non-zero status we log an error and skip the features update; the + * engine stays in HANDSHAKING (no probes should fire until the session + * succeeds). + */ + fun processConnectResponse(packet: AapPacket.ConnectResponse) { + dispatch(AapEngineEvent.ConnectResponseReceived(packet)) + } + private fun dispatch(event: AapEngineEvent) { when (event) { is AapEngineEvent.SessionStarted -> { @@ -112,13 +129,36 @@ internal class AapSessionEngine( } is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message) + is AapEngineEvent.ConnectResponseReceived -> handleConnectResponse(event.packet) is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update) is AapEngineEvent.TimerFired -> handleTimerFired(event.key) } } + private fun handleConnectResponse(packet: AapPacket.ConnectResponse) { + if (packet.status != 0) { + log(TAG, ERROR) { + "ConnectResponse failed: status=0x${"%04X".format(packet.status)} " + + "major=${packet.major} minor=${packet.minor} " + + "features=0x${"%016X".format(packet.features.toLong())}" + } + _state.value = _state.value.copy(connectResponseStatus = packet.status) + return + } + + log(TAG, INFO) { + "ConnectResponse OK: major=${packet.major} minor=${packet.minor} " + + "features=0x${"%016X".format(packet.features.toLong())}" + } + _state.value = _state.value.copy( + negotiatedFeatures = packet.features, + connectResponseStatus = packet.status, + lastMessageAt = timeSource.now(), + ) + } + private fun handleMessageReceived(message: AapMessage) { - if (message.commandType != CMD_HID_DESCRIPTOR) { + if (message.commandType != AapMessageType.BUDDY_COMMAND.value) { val hex = message.raw.joinToString(" ") { "%02X".format(it) } log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" @@ -127,7 +167,7 @@ internal class AapSessionEngine( } if (!runtimeState.handshakeResponseReceived && - message.commandType != CMD_SETTING && + message.commandType != AapMessageType.CONTROL.value && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING ) { runtimeState = runtimeState.copy(handshakeResponseReceived = true) @@ -135,13 +175,13 @@ internal class AapSessionEngine( log(TAG) { "Connection READY" } } - if (message.commandType == CMD_HID_DESCRIPTOR) { + if (message.commandType == AapMessageType.BUDDY_COMMAND.value) { hidTracker.consume(message.payload) _state.value = _state.value.copy(lastMessageAt = timeSource.now()) return } - if (message.commandType == CMD_DEVICE_INFO) { + if (message.commandType == AapMessageType.INFORMATION.value) { logDeviceInfoDiagnostics(message.payload) } @@ -186,6 +226,24 @@ internal class AapSessionEngine( log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" } } + is AapInboundUpdate.CaseInfo -> { + _state.value = _state.value.copy(caseInfo = update.info, lastMessageAt = timeSource.now()) + val hex = update.info.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Case info: ${update.info.rawPayload.size}B payload=[$hex]" } + } + + is AapInboundUpdate.SleepEvent -> { + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" } + } + + is AapInboundUpdate.DynamicEndOfChargeEvent -> { + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) } + log(TAG, INFO) { "Dynamic EoC event: ${update.event.rawPayload.size}B payload=[$hex]" } + } + is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value) } } @@ -411,20 +469,24 @@ internal class AapSessionEngine( "DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}" } segments.forEach { seg -> + // Labels per the Wireshark AAP dissector. The segmenter knows the schema — + // segments 11 and 12 are fixed 17-byte UUIDs. val label = when (seg.index) { 0 -> "name" 1 -> "modelNumber" 2 -> "manufacturer" 3 -> "serialNumber" 4 -> "firmwareVersion" - 5 -> "firmwareVersionDup" - 6 -> "protocolVersion" - 7 -> "updaterAppId" + 5 -> "firmwareVersionPending" + 6 -> "hardwareVersion" + 7 -> "eaProtocolName" 8 -> "leftEarbudSerial" 9 -> "rightEarbudSerial" - 10 -> "buildNumber" - 11 -> "encryptedBlob" - 12 -> "timestamp" + 10 -> "marketingVersion" + 11 -> "leftEarbudUuid (17 bytes fixed)" + 12 -> "rightEarbudUuid (17 bytes fixed)" + 13 -> "leftEarbudFirstPaired" + 14 -> "rightEarbudFirstPaired" else -> "unknown" } val rendered = seg.utf8?.let { "\"$it\"" } ?: "" @@ -438,7 +500,7 @@ internal class AapSessionEngine( val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } val sendInfo = currentSendDebugInfo() - if (message.commandType == CMD_SETTING && message.payload.size >= 2) { + if (message.commandType == AapMessageType.CONTROL.value && message.payload.size >= 2) { val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF val boolHint = value.appleBoolHint() @@ -461,7 +523,7 @@ internal class AapSessionEngine( return } - if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) { + if (message.commandType == AapMessageType.MAC_ADDRESS.value && message.payload.size >= 6) { val macRaw = message.payload.formatMac(reverse = false) val macReversed = message.payload.formatMac(reverse = true) val tailHex = if (message.payload.size > 6) { @@ -472,7 +534,7 @@ internal class AapSessionEngine( _state.value = _state.value.copy(lastMessageAt = timeSource.now()) log(TAG, VERBOSE) { buildString { - append("Known cmd=0x000C") + append("Known cmd=0x000C (${AapMessageType.MAC_ADDRESS.wiresharkName})") append(" payload=${message.payload.size}B") append(" macRaw=$macRaw macReversed=$macReversed") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") @@ -484,26 +546,56 @@ internal class AapSessionEngine( if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) { _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + val namedType = AapMessageType.byValue(message.commandType) + val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: "" log(TAG, VERBOSE) { - "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" + "Known cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } return } + val namedType = AapMessageType.byValue(message.commandType) + val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: " (unknown)" log(TAG, INFO) { - "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" + "Unhandled cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } } companion object { private val TAG = logTag("AAP", "Engine") - private const val CMD_SETTING = 0x0009 - private const val CMD_CONNECTED_DEVICE = 0x000C - private const val CMD_DEVICE_INFO = 0x001D - private const val CMD_HID_DESCRIPTOR = 0x0017 - private val KNOWN_NON_SETTINGS_COMMANDS = setOf( - 0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057, + /** + * Opcodes we see on-wire but don't model as a domain update. Decoded only + * enough to refresh `lastMessageAt` (freshness feeds the AAP quality boost + * in PodDevice.computeAapBoost). Add newly-catalogued push-only opcodes here + * when they appear in captures so logs get a named label AND freshness stays + * intact. + */ + private val KNOWN_NON_SETTINGS_COMMANDS: Set = setOf( + 0x0000, // Connect (shouldn't reach here but historically observed) + AapMessageType.CAPABILITIES.value, // 0x0002 + AapMessageType.DEVICE_LIST.value, // 0x000B + AapMessageType.TRIANGLE_STATUS_REQUEST.value, // 0x0015 + AapMessageType.MAGNET_LINK.value, // 0x0016 + AapMessageType.TIMESTAMP.value, // 0x001B + AapMessageType.UNKNOWN_0X21.value, // 0x0021 + AapMessageType.CASE_INFO.value, // 0x0023 (handled via decoder later; still refresh) + AapMessageType.GYRO_INFO.value, // 0x0028 + AapMessageType.STREAM_STATE_INFO.value, // 0x002B + AapMessageType.GAPA_CHALLENGE.value, // 0x002C + AapMessageType.UNKNOWN_0X40.value, // 0x0040 + AapMessageType.ADAPTIVE_VOLUME_MESSAGE.value, // 0x004C + AapMessageType.SOURCE_FEATURE_CAPABILITIES.value, // 0x004D + AapMessageType.FEATURE_PROX_CARD_STATUS_UPDATE.value, // 0x004E + AapMessageType.UARP_DATA.value, // 0x004F + AapMessageType.UNKNOWN_0X50.value, // 0x0050 + AapMessageType.SOURCE_CONTEXT.value, // 0x0052 + AapMessageType.SET_BAND_EDGES.value, // 0x0054 (real EQ; distinct from 0x53 PmeConfig which CAPod decodes) + AapMessageType.UNKNOWN_0X55.value, // 0x0055 + AapMessageType.SLEEP_DETECTION_UPDATE.value, // 0x0057 + AapMessageType.UNKNOWN_0X58.value, // 0x0058 + AapMessageType.DYNAMIC_END_OF_CHARGE.value, // 0x0059 + AapMessageType.PERSONAL_TRANSLATION.value, // 0x0060 ) } } @@ -517,6 +609,7 @@ internal sealed interface AapEngineEvent { data object HandshakeSent : AapEngineEvent data object ResetRequested : AapEngineEvent data class MessageReceived(val message: AapMessage) : AapEngineEvent + data class ConnectResponseReceived(val packet: AapPacket.ConnectResponse) : AapEngineEvent data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent data class TimerFired(val key: EngineTimerKey) : AapEngineEvent } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt index 34711fb3..4944258e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt @@ -42,6 +42,12 @@ internal class HidTracker(private val log: (String) -> Unit) { log("HID: terminator (${type.payloadSize}B)") } + is HidFrameType.ServiceInfo -> { + flush() + val tokens = type.asciiTokens.joinToString(", ") + log("HID: service info tokens=[$tokens] (${type.payloadSize}B)") + } + is HidFrameType.Other -> { flush() val hex = payload.joinToString(" ") { "%02X".format(it) } @@ -70,10 +76,25 @@ internal class HidTracker(private val log: (String) -> Unit) { data class ServiceDirectory(val services: List) : HidFrameType() data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() data class Terminator(val payloadSize: Int) : HidFrameType() + + /** + * 0x0017 "service info" frames — a TLV-ish metadata dump that carries + * ASCII key/value pairs like `VendorID`, `SerialNumber`, `CFG`, + * `ReportDescriptor`, etc. + * + * We don't decode the TLV structure yet (the framing is not fully + * documented). Instead we extract all printable ASCII runs of length + * ≥ 3 so the log tells you which keys/values the frame contains. + */ + data class ServiceInfo(val asciiTokens: List, val payloadSize: Int) : HidFrameType() + data object Other : HidFrameType() } companion object { + /** Minimum length for an ASCII run to count as a token in a ServiceInfo frame. */ + private const val MIN_ASCII_TOKEN_LEN = 3 + internal fun classify(payload: ByteArray): HidFrameType { // Service directory frame — starts with FE 00 00 and contains repeated // [len=4? ascii service name + 4B flags] blocks. For logging, extract names. @@ -111,7 +132,40 @@ internal class HidTracker(private val log: (String) -> Unit) { return HidFrameType.Terminator(payload.size) } + // "Service info" frame — 4-byte magic 00 00 10 00 followed by a TLV-ish + // payload with ASCII keys and mixed binary values. Observed on AirPods Pro 2 + // USB-C after the descriptor batch. + if (payload.size >= 8 && + payload[0] == 0x00.toByte() && + payload[1] == 0x00.toByte() && + payload[2] == 0x10.toByte() && + payload[3] == 0x00.toByte() + ) { + return HidFrameType.ServiceInfo( + asciiTokens = extractAsciiTokens(payload), + payloadSize = payload.size, + ) + } + return HidFrameType.Other } + + /** Extract printable ASCII runs ≥ 3 chars. Skips all non-printable bytes. */ + private fun extractAsciiTokens(payload: ByteArray): List { + val tokens = mutableListOf() + var i = 0 + while (i < payload.size) { + val startByte = payload[i].toInt() and 0xFF + if (startByte in 0x20..0x7E) { + val start = i + while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++ + val run = String(payload, start, i - start, Charsets.US_ASCII) + if (run.length >= MIN_ASCII_TOKEN_LEN) tokens += run + } else { + i++ + } + } + return tokens + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt new file mode 100644 index 00000000..663bcad9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCaseInfo.kt @@ -0,0 +1,54 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Information about the case itself (message type 0x23, requested via 0x22). + * + * Fields defined by the Wireshark AAP dissector: + * - CaseInfoMessageVersion + * - CaseInfoVID / CaseInfoPID / CaseInfoVIDSource + * - CaseInfoColor + * - CaseInfoVersion (case firmware) + * - CaseInfoName + * + * The Wireshark dissector does not yet decode the per-field byte offsets + * reliably — the payload length and presence of each field varies across + * models. This class holds the raw bytes for future iteration once more + * captures are available, and a best-effort named-field view. + */ +data class AapCaseInfo( + /** The complete payload (after the 6-byte header) for future analysis. */ + val rawPayload: ByteArray, + val messageVersion: Int? = null, + val vid: Int? = null, + val pid: Int? = null, + val vidSource: Int? = null, + val color: Int? = null, + val version: String? = null, + val name: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapCaseInfo) return false + if (!rawPayload.contentEquals(other.rawPayload)) return false + if (messageVersion != other.messageVersion) return false + if (vid != other.vid) return false + if (pid != other.pid) return false + if (vidSource != other.vidSource) return false + if (color != other.color) return false + if (version != other.version) return false + if (name != other.name) return false + return true + } + + override fun hashCode(): Int { + var r = rawPayload.contentHashCode() + r = 31 * r + (messageVersion ?: 0) + r = 31 * r + (vid ?: 0) + r = 31 * r + (pid ?: 0) + r = 31 * r + (vidSource ?: 0) + r = 31 * r + (color ?: 0) + r = 31 * r + (version?.hashCode() ?: 0) + r = 31 * r + (name?.hashCode() ?: 0) + return r + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt new file mode 100644 index 00000000..1e68ad4f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlId.kt @@ -0,0 +1,134 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Catalog of AAP control/setting IDs — the 8-bit ID at payload byte 0 of a + * Control message (see [AapMessageType.CONTROL], opcode 0x0009). + * + * Sources: + * - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua + * - LibrePods / MagicPodsCore + * - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3) + * + * Where CAPod ships a setting under a different user-facing name than the + * Wireshark catalog uses, both names are preserved — the enum uses the + * Wireshark name, UI-facing strings use the CAPod name. + */ +enum class AapControlId(val value: Int, val wiresharkName: String) { + MIC_MODE(0x01, "Mic Mode"), + SCAN(0x02, "Scan"), + RESET(0x03, "Reset"), + BASIC_DOUBLE_TAP_MODE(0x04, "Basic Double Tap Mode"), + BUTTON_SEND_MODE(0x05, "Button Send Mode"), + OWNERSHIP_STATE(0x06, "Ownership state"), + TAP_INTERVAL(0x07, "Tap Interval"), + + /** Request the connected bud to go secondary. */ + BUD_ROLE(0x08, "Bud Role"), + + DEBUG_GET_DATA(0x09, "Debug Get Data"), + IN_EAR_DETECTION(0x0A, "In Ear Detection"), + + /** Aka "Dynamic Latency". */ + JITTER_BUFFER(0x0B, "Jitter Buffer"), + + DOUBLE_TAP_MODE(0x0C, "Double Tap Mode"), + LISTEN_MODE(0x0D, "Listen Mode"), + HEART_RATE_MONITOR_1(0x0E, "Heart Rate Monitor"), + HEART_RATE_MONITOR_2(0x0F, "Heart Rate Monitor"), + UNKNOWN_0X10(0x10, "Unknown/Unassigned"), + SWITCH_CONTROL(0x11, "Switch Control"), + VOICE_TRIGGER(0x12, "Voice Trigger"), + + /** "Dictation over AirPods" for Siri. */ + DOAP_MODE(0x13, "DoAP mode"), + + SINGLE_CLICK(0x14, "Single Click"), + DOUBLE_CLICK(0x15, "Double Click"), + CLICK_AND_HOLD(0x16, "Click and Hold"), + + /** CAPod ships this as "Press Speed". */ + DOUBLE_CLICK_INTERVAL(0x17, "Double Click Interval"), + + /** CAPod ships this as "Press Hold Duration". */ + CLICK_AND_HOLD_INTERVAL(0x18, "Click and Hold Interval"), + + UNKNOWN_0X19(0x19, "Unknown/Unassigned"), + LISTENING_MODE_CONFIGS(0x1A, "Listening Mode Configs"), + ONE_BUD_ANC_MODE(0x1B, "One Bud ANC Mode"), + CROWN_ROTATION_DIRECTION(0x1C, "Crown Rotation Direction"), + UNKNOWN_0X1D(0x1D, "Unknown/Unassigned"), + AUTO_ANSWER_MODE(0x1E, "Auto Answer Mode"), + + /** CAPod ships this as "Tone Volume". */ + CHIME_VOLUME(0x1F, "Chime Volume"), + + SMART_ROUTING_MODE(0x20, "Smart Routing Mode"), + UNKNOWN_0X21(0x21, "Unknown/Unassigned"), + HFP_UPLINK_MODE(0x22, "HFP Uplink Mode"), + + /** CAPod ships this as "Volume Swipe Length". */ + VOLUME_SWIPE_INTERVAL(0x23, "Volume Swipe Interval"), + + /** CAPod ships this as "End Call / Mute Mic". */ + CALL_MANAGEMENT_CONFIG(0x24, "Call Management Config"), + + /** CAPod ships this as "Volume Swipe". */ + VOLUME_SWIPE_MODE(0x25, "Volume Swipe Mode"), + + /** + * "Adaptive Volume" per Wireshark. CAPod ships it as "Personalized Volume" + * because that matches the iOS Settings.app label — unclear if this is + * the same feature or the two sources disagree on naming. + */ + ADAPTIVE_VOLUME(0x26, "Adaptive Volume"), + + SOFTWARE_MUTE(0x27, "Software Mute"), + + /** CAPod ships this as "Conversational Awareness". */ + CONVERSATION_DETECT(0x28, "Conversation Detect"), + + SELECTIVE_SPEECH_LISTENING(0x29, "Selective Speech Listening"), + UNKNOWN_0X2A(0x2A, "Unknown/Unassigned"), + UNKNOWN_0X2B(0x2B, "Unknown/Unassigned"), + HEARING_AID(0x2C, "Hearing Aid"), + UNKNOWN_0X2D(0x2D, "Unknown/Unassigned"), + + /** CAPod ships this as "Adaptive Audio Noise". */ + AUTO_ANC_STRENGTH(0x2E, "Auto ANC Strength"), + + HEARING_AID_GAIN_SWIPE(0x2F, "Hearing Aid Gain Swipe"), + HEART_RATE_MONITOR_3(0x30, "Heart Rate Monitor"), + IN_CASE_TONE(0x31, "In-Case Tone"), + SIRI_MULTITONE(0x32, "Siri Multitone"), + HEARING_ASSIST(0x33, "Hearing Assist"), + ALLOW_OFF_OPTION(0x34, "Allow Off Option"), + SLEEP_DETECTION(0x35, "Sleep Detection"), + ALLOW_AUTO_CONNECT_FROM_AUDIO_ACCESSORY(0x36, "Allow Auto Connect from Audio Accessory"), + HEARING_PROTECTION_PPE(0x37, "Hearing Protection PPE"), + PPE_CAP_LEVEL_CONFIG(0x38, "PPE Cap Level Config"), + + /** CAPod ships this as "Stem Config". */ + RAW_GESTURES_CONFIG(0x39, "Raw Gestures Config"), + + ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A, "Allow Temporary Managed Pairing"), + DYNAMIC_END_OF_CHARGE(0x3B, "Dynamic End of Charge"), + SYSTEM_SIRI_MODE(0x3C, "System Siri Mode"), + + /** "hearingAidV2SourceRegionSupport" per Wireshark. */ + HEARING_AID_GENERIC(0x3D, "Hearing Aid Generic"), + + UPLINK_EQ_BUD(0x3E, "Uplink EQ Bud"), + UPLINK_EQ_SOURCE(0x3F, "Uplink EQ Source"), + + /** Separate from [IN_CASE_TONE] (0x31) — this is a volume level, not an on/off. */ + IN_CASE_TONE_VOLUME(0x40, "In Case Tone Volume"), + + DISABLE_BUTTON_INPUT(0x41, "Disable Button Input"), + EXTENDED_HOLD_AND_RELEASE(0x42, "Extended Hold and Release"), + ; + + companion object { + private val byValue: Map = entries.associateBy { it.value } + fun byValue(value: Int): AapControlId? = byValue[value] + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt index dd3fe02d..1ef760ba 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceInfo.kt @@ -1,15 +1,96 @@ package eu.darken.capod.pods.core.apple.aap.protocol +import java.time.Instant + /** - * Device identity parsed from the AAP handshake response (message type 0x1D). + * Device identity parsed from the AAP Information message (type 0x1D). + * + * Field ordering and semantics are sourced from the Wireshark AAP dissector: + * + * + * Segments 0-10 are NUL-delimited UTF-8. Segments 11-12 are fixed 17-byte + * UUIDs (may contain 0x00 — never treat as strings). Segments 13-14 are + * NUL-delimited timestamps (Unix epoch seconds, ASCII). + * + * Any field past the system fields may be absent on older devices or + * truncated payloads — all optional fields are nullable. */ data class AapDeviceInfo( + /** Segment 0 — user-visible device name ("AirPods Pro"). */ val name: String, + /** Segment 1 — Apple model identifier ("A2084"). */ val modelNumber: String, + /** Segment 2 — manufacturer string ("Apple Inc."). */ val manufacturer: String, + /** Segment 3 — system (case) serial number. */ val serialNumber: String, + /** Segment 4 — currently-running firmware version. */ val firmwareVersion: String, + /** Segment 5 — pending firmware version after next reboot. Null if equal to active. */ + val firmwareVersionPending: String? = null, + /** Segment 6 — hardware revision ("1.0.0"). */ + val hardwareVersion: String? = null, + /** Segment 7 — External Accessory protocol name ("com.apple.accessory.updater.app.71"). */ + val eaProtocolName: String? = null, + /** Segment 8 — left earbud serial. */ val leftEarbudSerial: String? = null, + /** Segment 9 — right earbud serial. */ val rightEarbudSerial: String? = null, - val buildNumber: String? = null, -) + /** + * Segment 10 — marketing/build version (e.g. "8454624"). Originally + * mis-labeled `buildNumber` in CAPod; the Wireshark dissector calls it + * "Marketing Version". + */ + val marketingVersion: String? = null, + /** Segment 11 — opaque 17-byte left-bud UUID. May contain arbitrary bytes. */ + val leftEarbudUuid: ByteArray? = null, + /** Segment 12 — opaque 17-byte right-bud UUID. May contain arbitrary bytes. */ + val rightEarbudUuid: ByteArray? = null, + /** Segment 13 — first-time-pairing timestamp for the left bud. */ + val leftEarbudFirstPaired: Instant? = null, + /** Segment 14 — first-time-pairing timestamp for the right bud. */ + val rightEarbudFirstPaired: Instant? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapDeviceInfo) return false + if (name != other.name) return false + if (modelNumber != other.modelNumber) return false + if (manufacturer != other.manufacturer) return false + if (serialNumber != other.serialNumber) return false + if (firmwareVersion != other.firmwareVersion) return false + if (firmwareVersionPending != other.firmwareVersionPending) return false + if (hardwareVersion != other.hardwareVersion) return false + if (eaProtocolName != other.eaProtocolName) return false + if (leftEarbudSerial != other.leftEarbudSerial) return false + if (rightEarbudSerial != other.rightEarbudSerial) return false + if (marketingVersion != other.marketingVersion) return false + if (!leftEarbudUuid.contentOptionalEquals(other.leftEarbudUuid)) return false + if (!rightEarbudUuid.contentOptionalEquals(other.rightEarbudUuid)) return false + if (leftEarbudFirstPaired != other.leftEarbudFirstPaired) return false + if (rightEarbudFirstPaired != other.rightEarbudFirstPaired) return false + return true + } + + override fun hashCode(): Int { + var r = name.hashCode() + r = 31 * r + modelNumber.hashCode() + r = 31 * r + manufacturer.hashCode() + r = 31 * r + serialNumber.hashCode() + r = 31 * r + firmwareVersion.hashCode() + r = 31 * r + (firmwareVersionPending?.hashCode() ?: 0) + r = 31 * r + (hardwareVersion?.hashCode() ?: 0) + r = 31 * r + (eaProtocolName?.hashCode() ?: 0) + r = 31 * r + (leftEarbudSerial?.hashCode() ?: 0) + r = 31 * r + (rightEarbudSerial?.hashCode() ?: 0) + r = 31 * r + (marketingVersion?.hashCode() ?: 0) + r = 31 * r + (leftEarbudUuid?.contentHashCode() ?: 0) + r = 31 * r + (rightEarbudUuid?.contentHashCode() ?: 0) + r = 31 * r + (leftEarbudFirstPaired?.hashCode() ?: 0) + r = 31 * r + (rightEarbudFirstPaired?.hashCode() ?: 0) + return r + } +} + +private fun ByteArray?.contentOptionalEquals(other: ByteArray?): Boolean = + if (this == null || other == null) this === other else contentEquals(other) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt index efdb9ccd..e1d27dd9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDeviceProfile.kt @@ -74,6 +74,33 @@ interface AapDeviceProfile { */ fun decodeStemPress(message: AapMessage): StemPressEvent? + /** + * Encode a Case Info request (command 0x22). Returns null when this profile + * shouldn't probe the case — older models may not respond, or the payload + * schema is unconfirmed for that model. Fire-and-forget: if the device + * doesn't reply, nothing happens. + */ + fun encodeCaseInfoRequest(): ByteArray? = null + + /** + * Decode a Case Info response (command 0x23). Returns null if the message + * is not a Case Info response or the payload cannot be recognised. + */ + fun decodeCaseInfo(message: AapMessage): AapCaseInfo? = null + + /** + * Decode a Sleep Detection Update event (command 0x57). Returns null for + * any other message type. Payload schema is not fully documented — the + * default implementation returns the raw bytes verbatim. + */ + fun decodeSleepEvent(message: AapMessage): AapSleepEvent? = null + + /** + * Decode a Dynamic End-of-Charge event (command 0x59). Returns null for + * any other message type. + */ + fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? = null + companion object { fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt new file mode 100644 index 00000000..732dc0bf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapDynamicEndOfChargeEvent.kt @@ -0,0 +1,20 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Push-only event paired with the Dynamic End-of-Charge setting (control ID 0x3B). + * Message type 0x59 — reports charge-cap status transitions (e.g. "80% cap reached"). + * + * Payload schema is not yet documented publicly — this type preserves the raw + * bytes so future capture-based analysis can fill in structured fields. + */ +data class AapDynamicEndOfChargeEvent( + val rawPayload: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapDynamicEndOfChargeEvent) return false + return rawPayload.contentEquals(other.rawPayload) + } + + override fun hashCode(): Int = rawPayload.contentHashCode() +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt similarity index 59% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt index 62765a04..d8f7242c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapFramer.kt @@ -1,36 +1,5 @@ package eu.darken.capod.pods.core.apple.aap.protocol -/** - * A parsed AAP protocol message. - */ -data class AapMessage( - val raw: ByteArray, - val commandType: Int, - val payload: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is AapMessage) return false - return raw.contentEquals(other.raw) - } - - override fun hashCode(): Int = raw.contentHashCode() - - companion object { - /** - * Parse a complete AAP message from raw bytes. - * AAP messages have the format: [4-byte header] [2-byte command type] [payload…] - * Minimum message size is 6 bytes (header + command type with no payload). - */ - fun parse(raw: ByteArray): AapMessage? { - if (raw.size < 6) return null - val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8) - val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0) - return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload) - } - } -} - /** * Accumulates bytes from a stream and emits complete [AapMessage] objects. * @@ -39,6 +8,12 @@ data class AapMessage( * * AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian) * indicate total message length (excluding the first 4 header bytes). + * + * Note: production code bypasses this framer — [AapConnection.readLoop] + * parses whole reads directly because L2CAP SEQPACKET already delivers + * per-frame boundaries. The framer is retained for future stream-mode + * paths but its splitting rule doesn't match every real capture + * (see `AapFramerTest` for the shape it expects). */ class AapFramer { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt new file mode 100644 index 00000000..ef09d761 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt @@ -0,0 +1,112 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Catalog of AAP (Apple Accessory Protocol, also "AACP") message types — + * the 16-bit opcode at bytes 4-5 of a Message-type packet (packet type 0x04). + * + * Sources: + * - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua + * - LibrePods / MagicPodsCore + * - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3) + * + * Entries whose names start with `UNKNOWN_` exist in Wireshark's dissector + * but have no known meaning. They're catalogued here so unhandled-message + * logging still reports a named opcode instead of a bare int. + */ +enum class AapMessageType(val value: Int, val wiresharkName: String) { + CAPABILITIES_REQUEST(0x0001, "Capabilities Request"), + CAPABILITIES(0x0002, "Capabilities"), + BATTERY_INFO_REQUEST(0x0003, "Battery Info Request"), + BATTERY_INFO(0x0004, "Battery Info"), + EAR_DETECTION_REQUEST(0x0005, "Ear Detection Request"), + EAR_DETECTION(0x0006, "Ear Detection"), + BUD_ROLE_REQUEST(0x0007, "Bud Role Request"), + BUD_ROLE(0x0008, "Bud Role"), + + /** Controls / settings. The control ID is payload byte 0 — see [AapControlId]. */ + CONTROL(0x0009, "Control"), + + DEVICE_LIST(0x000B, "Device List"), + MAC_ADDRESS(0x000C, "MAC Address"), + STREAM_STATE_INFO_REQUEST(0x000D, "Stream State Info Request"), + AUDIO_SOURCE(0x000E, "Audio Source"), + SET_NOTIFICATION_FILTER(0x000F, "Set Notification Filter"), + SMART_ROUTING_1(0x0010, "Smart Routing"), + SMART_ROUTING_2(0x0011, "Smart Routing"), + EASY_PAIR_REQUEST(0x0012, "Easy Pair Request?"), + CONNECT_PRIORITY_LIST(0x0014, "Connect Priority List"), + TRIANGLE_STATUS_REQUEST(0x0015, "Triangle Status Request"), + MAGNET_LINK(0x0016, "Magnet Link"), + + /** + * Buddy Command per Wireshark. In CAPod we treat payloads of this opcode as + * HID descriptor frames (Service Directory / descriptor / terminator — see [HidTracker]). + * Both interpretations may be correct for different sub-payloads. + */ + BUDDY_COMMAND(0x0017, "BuddyCommand"), + + STEM_PRESS(0x0019, "Stem Press"), + RENAME(0x001A, "Rename"), + TIMESTAMP(0x001B, "Timestamp"), + INFORMATION(0x001D, "Information"), + SEND_EXTERNAL_ACCESSORY_SESSION_PACKET(0x001E, "Send External Accessory Session Packet"), + NOTIFY_SESSION_STATE(0x001F, "Notify Session State?"), + SEND_REMOTE_FIRMWARE_AUTH_DATA(0x0020, "Send Remote Firmware Auth Data"), + UNKNOWN_0X21(0x0021, "Unknown"), + CASE_INFO_REQUEST(0x0022, "Case Info Request"), + CASE_INFO(0x0023, "Case Info"), + SEND_DEVICE_INFO(0x0024, "Send Device Info?"), + CERTIFICATES_REQUEST(0x0026, "Certificates Request"), + CERTIFICATES(0x0027, "Certificates"), + GYRO_INFO(0x0028, "Gyro Info"), + SET_COUNTRY_CODE(0x0029, "Set Country Code"), + STREAM_STATE_INFO(0x002B, "Stream State Info"), + GAPA_CHALLENGE(0x002C, "GAPA Challenge"), + CONNECTED_DEVICES_REQUEST(0x002D, "Connected Devices Request"), + CONNECTED_DEVICES(0x002E, "Connected Devices"), + MAGIC_KEYS_REQUEST(0x0030, "Magic Keys Request"), + MAGIC_KEYS(0x0031, "Magic Keys"), + MAGIC_KEYS_2(0x0032, "Magic Keys"), + UNKNOWN_0X40(0x0040, "Unknown"), + SEND_SMART_ROUTING_2_INFO(0x0044, "Send Smart Routing 2.0 Info"), + FAST_CONNECT_COMPLETE(0x0045, "Fast Connect Complete?"), + BUD_SWAP_2_PROCEDURE(0x0047, "Bud Swap 2.0 Procedure?"), + SWAP_IMMINENT_CONFIRM(0x0048, "Swap Imminent Confirm?"), + BUD_SWAP_2_COMPLETION(0x0049, "Bud Swap 2.0 Completion?"), + SWAP_COMPLETE_CONFIRM(0x004A, "Swap Complete Confirm?"), + CONVERSATIONAL_AWARENESS(0x004B, "Conversational Awareness"), + ADAPTIVE_VOLUME_MESSAGE(0x004C, "Adaptive Volume Message"), + + /** + * Source Feature Capabilities. Sent by the source after Connect Response to + * advertise what it supports. In CAPod this is "InitExt" — we only emit a + * fixed template, we don't read/decode the reply. + */ + SOURCE_FEATURE_CAPABILITIES(0x004D, "Source Feature Capabilities"), + + FEATURE_PROX_CARD_STATUS_UPDATE(0x004E, "Feature ProxCard Status Update"), + UARP_DATA(0x004F, "UARP Data"), + UNKNOWN_0X50(0x0050, "Unknown"), + SOURCE_CONTEXT(0x0052, "Source Context"), + + /** + * PME (Personal Mixing Engine?) config per Wireshark. CAPod decodes this as + * 4 × 8 Float32 values (see [AapSetting.PmeConfig]). Real EQ likely lives + * at [SET_BAND_EDGES]. + */ + PME_CONFIG(0x0053, "PME Config"), + + SET_BAND_EDGES(0x0054, "Set Band Edges"), + UNKNOWN_0X55(0x0055, "Unknown"), + USB_SPATIAL_SENSOR_DATA_REQUEST(0x0056, "USB Spatial Sensor Data Request"), + SLEEP_DETECTION_UPDATE(0x0057, "Sleep Detection Update"), + UNKNOWN_0X58(0x0058, "Unknown"), + DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"), + PERSONAL_TRANSLATION(0x0060, "Personal Translation"), + ; + + companion object { + private val byValue: Map = entries.associateBy { it.value } + fun byValue(value: Int): AapMessageType? = byValue[value] + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt new file mode 100644 index 00000000..39fec9a7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacket.kt @@ -0,0 +1,172 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * A parsed AAP (AACP) frame. Packet type lives at bytes 0-1 (little-endian) + * and selects which variant we're looking at. + * + * Per the Wireshark dissector, packet types are: + * - 0x0000 — Connect (session open request; source → pods) + * - 0x0001 — Connect Response (pods → source) + * - 0x0002 — Disconnect + * - 0x0003 — Disconnect Response + * - 0x0004 — Message (the large Message type with a 16-bit message/command ID) + * + * Only `Message` packets carry the `(commandType, payload)` pair that the + * existing decoder pipeline consumes; the other variants have their own + * field schema and bypass the decoder. + */ +sealed class AapPacket(val raw: ByteArray) { + + /** Source → pods session open. We emit this as our handshake. */ + class Connect( + raw: ByteArray, + val service: Int, + val major: Int, + val minor: Int, + val features: ULong, + ) : AapPacket(raw) + + /** + * Pods → source response to a Connect. The `features` bitmask is opaque + * for now (no public bit-to-feature mapping); CAPod logs it and stores + * it in `AapPodState` for future correlation work. + */ + class ConnectResponse( + raw: ByteArray, + val service: Int, + val status: Int, + val major: Int, + val minor: Int, + val features: ULong, + ) : AapPacket(raw) + + class Disconnect( + raw: ByteArray, + val service: Int, + val status: Int, + ) : AapPacket(raw) + + class DisconnectResponse( + raw: ByteArray, + val service: Int, + ) : AapPacket(raw) + + /** + * Message packet — bytes 4-5 are the message/command ID. This is what + * [AapDeviceProfile.decodeSetting] / `decodeBattery` / etc. consume. + * + * Typealiased to `AapMessage` for backward compatibility. + */ + class Message( + raw: ByteArray, + val commandType: Int, + val payload: ByteArray, + ) : AapPacket(raw) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Message) return false + return raw.contentEquals(other.raw) + } + + override fun hashCode(): Int = raw.contentHashCode() + + override fun toString(): String = + "AapPacket.Message(cmd=0x${"%04X".format(commandType)}, payload=${payload.size}B)" + + companion object { + /** + * Parse a Message-type AAP frame. Returns null if the bytes aren't + * a complete Message packet (packet type != 0x0004, or payload too short). + * Non-Message packets (Connect Response etc.) return null here — + * use [AapPacket.parse] if you need to handle them. + */ + fun parse(raw: ByteArray): Message? { + val packet = AapPacket.parse(raw) ?: return null + return packet as? Message + } + } + } + + /** Unknown / unrecognised packet type. Preserved for logging. */ + class Unknown(raw: ByteArray, val packetType: Int) : AapPacket(raw) + + companion object { + private const val PACKET_TYPE_CONNECT = 0x0000 + private const val PACKET_TYPE_CONNECT_RESPONSE = 0x0001 + private const val PACKET_TYPE_DISCONNECT = 0x0002 + private const val PACKET_TYPE_DISCONNECT_RESPONSE = 0x0003 + private const val PACKET_TYPE_MESSAGE = 0x0004 + + /** + * Parse a raw AAP frame. Returns: + * - `null` if the buffer is too short to even read the packet type. + * - An [Unknown] if the packet type isn't one we recognise. + * - The appropriate subclass otherwise. + * + * Note: this assumes one frame per call — L2CAP SEQPACKET delivers + * complete frames per read, matching this expectation. The + * (unused-in-prod) [AapFramer] uses a different splitting approach. + */ + fun parse(raw: ByteArray): AapPacket? { + if (raw.size < 4) return null + val packetType = readLe16(raw, 0) + val service = readLe16(raw, 2) + return when (packetType) { + PACKET_TYPE_CONNECT -> parseConnect(raw, service) + PACKET_TYPE_CONNECT_RESPONSE -> parseConnectResponse(raw, service) + PACKET_TYPE_DISCONNECT -> parseDisconnect(raw, service) + PACKET_TYPE_DISCONNECT_RESPONSE -> DisconnectResponse(raw.copyOf(), service) + PACKET_TYPE_MESSAGE -> parseMessage(raw) + else -> Unknown(raw.copyOf(), packetType) + } + } + + private fun parseConnect(raw: ByteArray, service: Int): Connect? { + if (raw.size < 16) return null + val major = readLe16(raw, 4) + val minor = readLe16(raw, 6) + val features = readLe64(raw, 8) + return Connect(raw.copyOf(), service, major, minor, features) + } + + private fun parseConnectResponse(raw: ByteArray, service: Int): ConnectResponse? { + if (raw.size < 18) return null + val status = readLe16(raw, 4) + val major = readLe16(raw, 6) + val minor = readLe16(raw, 8) + val features = readLe64(raw, 10) + return ConnectResponse(raw.copyOf(), service, status, major, minor, features) + } + + private fun parseDisconnect(raw: ByteArray, service: Int): Disconnect? { + if (raw.size < 6) return null + val status = readLe16(raw, 4) + return Disconnect(raw.copyOf(), service, status) + } + + private fun parseMessage(raw: ByteArray): Message? { + if (raw.size < 6) return null + val commandType = readLe16(raw, 4) + val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0) + return Message(raw = raw.copyOf(), commandType = commandType, payload = payload) + } + + private fun readLe16(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + + private fun readLe64(data: ByteArray, offset: Int): ULong { + var result = 0UL + for (i in 0 until 8) { + result = result or ((data[offset + i].toLong() and 0xFFL).toULong() shl (i * 8)) + } + return result + } + } +} + +/** + * Backward-compat alias — CAPod historically named the Message packet just + * `AapMessage`. Kept as a type alias so decoder signatures don't churn. + */ +typealias AapMessage = AapPacket.Message diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index 4174e2ee..6050c7be 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -163,9 +163,21 @@ sealed class AapSetting { enum class AudioSourceType { NONE, CALL, MEDIA } } - data class EqBands( + /** + * Payload of message type 0x0053. The Wireshark AAP dissector calls this + * "PME Config" (Personal Mixing Engine). CAPod used to label it as EQ + * bands, but captures so far show this data is all zeros on in-production + * firmware — the real equalizer path is likely 0x0054 "Set Band Edges". + * + * The payload is decoded as 4 sets × 8 Float32 values until the true schema + * is confirmed. Callers should treat all-zero [sets] as "no config reported". + */ + data class PmeConfig( val sets: List>, - ) : AapSetting() + ) : AapSetting() { + val isAllZero: Boolean + get() = sets.all { set -> set.all { it == 0f } } + } /** Per-pod placement reported by the device (command 0x06). */ data class EarDetection( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt new file mode 100644 index 00000000..699852df --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSleepEvent.kt @@ -0,0 +1,20 @@ +package eu.darken.capod.pods.core.apple.aap.protocol + +/** + * Push-only event emitted by newer AirPods firmware when the Sleep Detection + * feature (setting 0x35) is enabled (message type 0x57 "Sleep Detection Update"). + * + * Payload schema is not yet documented publicly — this type preserves the raw + * bytes so future capture-based analysis can fill in structured fields. + */ +data class AapSleepEvent( + val rawPayload: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AapSleepEvent) return false + return rawPayload.contentEquals(other.rawPayload) + } + + override fun hashCode(): Int = rawPayload.contentHashCode() +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index 7ddd2235..254c7fd4 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -16,58 +16,43 @@ class DefaultAapDeviceProfile( ) : AapDeviceProfile { companion object { - // AAP command types (bytes 4-5 of the message, little-endian) - const val CMD_SETTINGS = 0x0009 - const val CMD_BATTERY = 0x0004 - const val CMD_DEVICE_INFO = 0x001D - const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031 - const val CMD_EAR_DETECTION = 0x0006 - const val CMD_PRIMARY_POD = 0x0008 - const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B - - // Setting IDs (first byte of settings command payload) - const val SETTING_ANC_MODE = 0x0D - const val SETTING_PRESS_SPEED = 0x17 - const val SETTING_PRESS_HOLD_DURATION = 0x18 - const val SETTING_NC_ONE_AIRPOD = 0x1B - const val SETTING_TONE_VOLUME = 0x1F - const val SETTING_VOLUME_SWIPE_LENGTH = 0x23 - const val SETTING_END_CALL_MUTE_MIC = 0x24 - const val SETTING_VOLUME_SWIPE = 0x25 - const val SETTING_PERSONALIZED_VOLUME = 0x26 - const val SETTING_CONVERSATIONAL_AWARENESS = 0x28 - const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E - const val SETTING_MICROPHONE_MODE = 0x01 - const val SETTING_EAR_DETECTION_ENABLED = 0x0A - const val SETTING_LISTENING_MODE_CYCLE = 0x1A - // Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the - // decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh, - // which AAP freshness / boost logic in PodDevice.computeAapBoost depends on. - // Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here; - // the actual effect of this setting is unknown, so we don't expose or write it. - const val SETTING_IN_CASE_TONE = 0x31 - const val SETTING_ALLOW_OFF_OPTION = 0x34 - const val SETTING_SLEEP_DETECTION = 0x35 - const val SETTING_STEM_CONFIG = 0x39 - - // Known-but-unconfirmed setting IDs (H2+ exclusive). Decoded as UnknownSetting - // to keep lastMessageAt fresh. Observed on Pro 2 USB-C and/or Pro 3. - val UNCONFIRMED_SETTING_IDS = setOf( - 0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E, + /** + * Catalogued control IDs we see on-wire but don't yet model as a named + * [AapSetting] subclass. Decoded as [AapSetting.UnknownSetting] so + * `lastMessageAt` still refreshes (freshness feeds AAP quality boost). + * Promote an entry to a dedicated subclass when its semantics are + * confirmed from captures. + * + * Observed on Pro 2 USB-C and/or Pro 3. + */ + val UNMAPPED_SETTING_IDS = setOf( + AapControlId.SELECTIVE_SPEECH_LISTENING.value, // 0x29 + AapControlId.HEARING_AID.value, // 0x2C + AapControlId.HEARING_AID_GAIN_SWIPE.value, // 0x2F + AapControlId.HEART_RATE_MONITOR_3.value, // 0x30 + AapControlId.HEARING_ASSIST.value, // 0x33 + AapControlId.HEARING_PROTECTION_PPE.value, // 0x37 + AapControlId.PPE_CAP_LEVEL_CONFIG.value, // 0x38 + AapControlId.DYNAMIC_END_OF_CHARGE.value, // 0x3B + AapControlId.UPLINK_EQ_BUD.value, // 0x3E ) - // Command types for non-settings messages - const val CMD_RENAME = 0x001E - const val CMD_STEM_PRESS = 0x0019 - const val CMD_CONNECTED_DEVICES = 0x002E - const val CMD_AUDIO_SOURCE = 0x000E - const val CMD_EQ_DATA = 0x0053 - // ANC mode wire values const val ANC_WIRE_OFF = 0x01 const val ANC_WIRE_ON = 0x02 const val ANC_WIRE_TRANSPARENCY = 0x03 const val ANC_WIRE_ADAPTIVE = 0x04 + + /** Fixed length of each earbud UUID segment in the Information payload (segments 11 & 12). */ + private const val UUID_LEN = 17 + + /** + * Models where sending a 0x22 CASE_INFO_REQUEST yields a 0x23 response. + * Grow this list as captures confirm other models respond correctly. + */ + private val CASE_INFO_ALLOWLIST = setOf( + PodModel.AIRPODS_PRO3, + ) } private val supportedAncModes: List by lazy { @@ -95,31 +80,31 @@ class DefaultAapDeviceProfile( ) override fun encodeCommand(command: AapCommand): ByteArray = when (command) { - is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode)) - is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled)) - is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue) - is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue) - is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled)) - is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64)) - is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue) - is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled)) - is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled)) + is AapCommand.SetAncMode -> buildSettingsMessage(AapControlId.LISTEN_MODE.value, encodeAncMode(command.mode)) + is AapCommand.SetConversationalAwareness -> buildSettingsMessage(AapControlId.CONVERSATION_DETECT.value, encodeAppleBool(command.enabled)) + is AapCommand.SetPressSpeed -> buildSettingsMessage(AapControlId.DOUBLE_CLICK_INTERVAL.value, command.value.wireValue) + is AapCommand.SetPressHoldDuration -> buildSettingsMessage(AapControlId.CLICK_AND_HOLD_INTERVAL.value, command.value.wireValue) + is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(AapControlId.ONE_BUD_ANC_MODE.value, encodeAppleBool(command.enabled)) + is AapCommand.SetToneVolume -> buildSettingsMessage(AapControlId.CHIME_VOLUME.value, command.level.coerceIn(0x0F, 0x64)) + is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_INTERVAL.value, command.value.wireValue) + is AapCommand.SetVolumeSwipe -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_MODE.value, encodeAppleBool(command.enabled)) + is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(AapControlId.ADAPTIVE_VOLUME.value, encodeAppleBool(command.enabled)) // Wire semantics are inverted: wire 0 = max noise reduction, wire 100 = min (transparency-like). // UI value 0..100 follows user intuition (100 = max NC), so flip on write/read. - is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, 100 - command.level.coerceIn(0, 100)) + is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(AapControlId.AUTO_ANC_STRENGTH.value, 100 - command.level.coerceIn(0, 100)) is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall) - is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue) - is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled)) - is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F) - is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled)) - is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F) - is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled)) + is AapCommand.SetMicrophoneMode -> buildSettingsMessage(AapControlId.MIC_MODE.value, command.mode.wireValue) + is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(AapControlId.IN_EAR_DETECTION.value, encodeAppleBool(command.enabled)) + is AapCommand.SetListeningModeCycle -> buildSettingsMessage(AapControlId.LISTENING_MODE_CONFIGS.value, command.modeMask and 0x0F) + is AapCommand.SetAllowOffOption -> buildSettingsMessage(AapControlId.ALLOW_OFF_OPTION.value, encodeAppleBool(command.enabled)) + is AapCommand.SetStemConfig -> buildSettingsMessage(AapControlId.RAW_GESTURES_CONFIG.value, command.claimedPressMask and 0x0F) + is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled)) is AapCommand.SetDeviceName -> buildRenameMessage(command.name) } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { // Primary pod identity (push-only, fires on mic/primary swap) - if (message.commandType == CMD_PRIMARY_POD) { + if (message.commandType == AapMessageType.BUD_ROLE.value) { if (message.payload.size < 4) return null val podId = message.payload[0].toInt() and 0xFF // Validate known fixed bytes: [podId] 00 [00|01] [00|01] @@ -136,7 +121,7 @@ class DefaultAapDeviceProfile( } // Ear detection is a separate command type (push-only from device) - if (message.commandType == CMD_EAR_DETECTION) { + if (message.commandType == AapMessageType.EAR_DETECTION.value) { if (message.payload.size < 2) return null return AapSetting.EarDetection::class to AapSetting.EarDetection( primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF), @@ -145,7 +130,7 @@ class DefaultAapDeviceProfile( } // Connected devices list (push-only from device) - if (message.commandType == CMD_CONNECTED_DEVICES) { + if (message.commandType == AapMessageType.CONNECTED_DEVICES.value) { if (message.payload.size < 3) return null val count = message.payload[2].toInt() and 0xFF val devices = mutableListOf() @@ -161,7 +146,7 @@ class DefaultAapDeviceProfile( } // Audio source tracking (push-only from device) - if (message.commandType == CMD_AUDIO_SOURCE) { + if (message.commandType == AapMessageType.AUDIO_SOURCE.value) { if (message.payload.size < 7) return null val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":") val typeValue = message.payload[6].toInt() and 0xFF @@ -173,8 +158,11 @@ class DefaultAapDeviceProfile( return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type) } - // EQ data (push-only from device) - if (message.commandType == CMD_EQ_DATA) { + // 0x53 is "PME Config" per the Wireshark AAP dissector. CAPod decodes the + // 4-set × 8-band Float32 payload verbatim; captures so far have all-zero + // payloads so the schema is unconfirmed. Real EQ likely lives on 0x54 + // "Set Band Edges" with a different byte layout. + if (message.commandType == AapMessageType.PME_CONFIG.value) { if (message.payload.size < 6 + 128) return null val sets = mutableListOf>() var offset = 6 // skip header @@ -190,92 +178,97 @@ class DefaultAapDeviceProfile( } sets.add(bands) } - return AapSetting.EqBands::class to AapSetting.EqBands(sets) + return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets) } // Conversation Awareness State is a separate command type (push-only) - if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { + if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) { if (message.payload.isEmpty()) return null val value = message.payload[0].toInt() and 0xFF val speaking = value == 0x01 return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking) } - if (message.commandType != CMD_SETTINGS) return null + if (message.commandType != AapMessageType.CONTROL.value) return null if (message.payload.size < 2) return null val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF return when (settingId) { - SETTING_ANC_MODE -> { + AapControlId.LISTEN_MODE.value -> { val mode = decodeAncMode(value) ?: return null AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes) } - SETTING_CONVERSATIONAL_AWARENESS -> { + AapControlId.CONVERSATION_DETECT.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled) } - SETTING_PRESS_SPEED -> { + AapControlId.DOUBLE_CLICK_INTERVAL.value -> { val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed) } - SETTING_PRESS_HOLD_DURATION -> { + AapControlId.CLICK_AND_HOLD_INTERVAL.value -> { val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration) } - SETTING_NC_ONE_AIRPOD -> { + AapControlId.ONE_BUD_ANC_MODE.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled) } - SETTING_TONE_VOLUME -> { + AapControlId.CHIME_VOLUME.value -> { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value) } - SETTING_VOLUME_SWIPE_LENGTH -> { + AapControlId.VOLUME_SWIPE_INTERVAL.value -> { val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length) } - SETTING_END_CALL_MUTE_MIC -> { + AapControlId.CALL_MANAGEMENT_CONFIG.value -> { decodeEndCallMuteMic(message.payload) } - SETTING_VOLUME_SWIPE -> { + AapControlId.VOLUME_SWIPE_MODE.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled) } - SETTING_PERSONALIZED_VOLUME -> { + AapControlId.ADAPTIVE_VOLUME.value -> { + // Wireshark calls 0x26 "Adaptive Volume"; CAPod ships the user-facing name + // "Personalized Volume" because that matches the iOS Settings.app label. val enabled = decodeAppleBool(value) ?: return null AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled) } - SETTING_ADAPTIVE_AUDIO_NOISE -> { + AapControlId.AUTO_ANC_STRENGTH.value -> { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 100 - value.coerceIn(0, 100)) } - SETTING_MICROPHONE_MODE -> { + AapControlId.MIC_MODE.value -> { val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode) } - SETTING_EAR_DETECTION_ENABLED -> { + AapControlId.IN_EAR_DETECTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled) } - SETTING_LISTENING_MODE_CYCLE -> { + AapControlId.LISTENING_MODE_CONFIGS.value -> { AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value) } - SETTING_ALLOW_OFF_OPTION -> { + AapControlId.ALLOW_OFF_OPTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled) } - SETTING_STEM_CONFIG -> { + AapControlId.RAW_GESTURES_CONFIG.value -> { AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value) } - SETTING_SLEEP_DETECTION -> { + AapControlId.SLEEP_DETECTION.value -> { val enabled = decodeAppleBool(value) ?: return null AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled) } - SETTING_IN_CASE_TONE -> { + AapControlId.IN_CASE_TONE.value -> { + // Decoded internally (never exposed in UI) to keep lastMessageAt fresh. + // Originally labeled "Charging Sounds" — the real case tones are controlled + // over ATT, not here; the actual effect of this setting is unknown. val enabled = decodeAppleBool(value) ?: return null AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) } - in UNCONFIRMED_SETTING_IDS -> { + in UNMAPPED_SETTING_IDS -> { AapSetting.UnknownSetting::class to AapSetting.UnknownSetting( settingId = settingId, rawValue = value, @@ -286,7 +279,7 @@ class DefaultAapDeviceProfile( } override fun decodeBattery(message: AapMessage): Map? { - if (message.commandType != CMD_BATTERY) return null + if (message.commandType != AapMessageType.BATTERY_INFO.value) return null val payload = message.payload if (payload.isEmpty()) return null @@ -328,7 +321,7 @@ class DefaultAapDeviceProfile( ) override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? { - if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null + if (message.commandType != AapMessageType.MAGIC_KEYS.value) return null val payload = message.payload if (payload.isEmpty()) return null @@ -360,26 +353,31 @@ class DefaultAapDeviceProfile( } override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? { - if (message.commandType != CMD_DEVICE_INFO) return null + if (message.commandType != AapMessageType.INFORMATION.value) return null if (message.payload.size < 10) return null - // Device info payload contains null-terminated UTF-8 strings - // Format: [binary header] [NUL-delimited strings...] - val strings = parseNullTerminatedStrings(message.payload) - if (strings.size < 4) return null + val parsed = parseDeviceInfoPayload(message.payload) ?: return null + if (parsed.strings.size < 4) return null + + val activeFirmware = parsed.strings.getOrElse(4) { "" } + val pendingFirmware = parsed.strings.getOrNull(5)?.takeIf { it.isNotBlank() && it != activeFirmware } return AapDeviceInfo( - name = strings.getOrElse(0) { "" }, - modelNumber = strings.getOrElse(1) { "" }, - manufacturer = strings.getOrElse(2) { "" }, - serialNumber = strings.getOrElse(3) { "" }, - firmwareVersion = strings.getOrElse(4) { "" }, - // Segments [5]=firmware dup, [6]=protocol version, [7]=updater app ID — skipped - // Segments [8] and [9] are individual earbud serials (observed on Pro 1, Pro 2, Pro 3) - leftEarbudSerial = strings.getOrNull(8)?.takeIf { it.isNotBlank() }, - rightEarbudSerial = strings.getOrNull(9)?.takeIf { it.isNotBlank() }, - // Segment [10] is the build number (e.g. "8454624") - buildNumber = strings.getOrNull(10)?.takeIf { it.isNotBlank() }, + name = parsed.strings.getOrElse(0) { "" }, + modelNumber = parsed.strings.getOrElse(1) { "" }, + manufacturer = parsed.strings.getOrElse(2) { "" }, + serialNumber = parsed.strings.getOrElse(3) { "" }, + firmwareVersion = activeFirmware, + firmwareVersionPending = pendingFirmware, + hardwareVersion = parsed.strings.getOrNull(6)?.takeIf { it.isNotBlank() }, + eaProtocolName = parsed.strings.getOrNull(7)?.takeIf { it.isNotBlank() }, + leftEarbudSerial = parsed.strings.getOrNull(8)?.takeIf { it.isNotBlank() }, + rightEarbudSerial = parsed.strings.getOrNull(9)?.takeIf { it.isNotBlank() }, + marketingVersion = parsed.strings.getOrNull(10)?.takeIf { it.isNotBlank() }, + leftEarbudUuid = parsed.leftEarbudUuid, + rightEarbudUuid = parsed.rightEarbudUuid, + leftEarbudFirstPaired = parsed.leftEarbudFirstPaired, + rightEarbudFirstPaired = parsed.rightEarbudFirstPaired, ) } @@ -445,7 +443,7 @@ class DefaultAapDeviceProfile( return byteArrayOf( 0x04, 0x00, 0x04, 0x00, 0x09, 0x00, - SETTING_END_CALL_MUTE_MIC.toByte(), 0x20, + AapControlId.CALL_MANAGEMENT_CONFIG.value.toByte(), 0x20, combined.toByte(), 0x00, 0x00, ) @@ -480,13 +478,49 @@ class DefaultAapDeviceProfile( } override fun decodeStemPress(message: AapMessage): StemPressEvent? { - if (message.commandType != CMD_STEM_PRESS) return null + if (message.commandType != AapMessageType.STEM_PRESS.value) return null if (message.payload.size < 2) return null val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null return StemPressEvent(pressType, bud) } + /** + * Case Info probing is allowlisted to models observed to respond. Pro 3 is the + * confirmed first entry; other models can be added once captures verify they + * reply to 0x22 with a 0x23 payload. + */ + override fun encodeCaseInfoRequest(): ByteArray? { + if (model !in CASE_INFO_ALLOWLIST) return null + // Fire-and-forget 6-byte template, matching the private key request shape + // (header + command type with no payload). + return byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + AapMessageType.CASE_INFO_REQUEST.value.toByte(), 0x00, + ) + } + + /** + * Best-effort decoder. Per the dissector, the payload contains a mix of + * binary VID/PID/color bytes plus a NUL-delimited name string. Without + * more captures we preserve the raw payload and leave individual fields + * null — future iteration can map specific offsets. + */ + override fun decodeCaseInfo(message: AapMessage): AapCaseInfo? { + if (message.commandType != AapMessageType.CASE_INFO.value) return null + return AapCaseInfo(rawPayload = message.payload.copyOf()) + } + + override fun decodeSleepEvent(message: AapMessage): AapSleepEvent? { + if (message.commandType != AapMessageType.SLEEP_DETECTION_UPDATE.value) return null + return AapSleepEvent(rawPayload = message.payload.copyOf()) + } + + override fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? { + if (message.commandType != AapMessageType.DYNAMIC_END_OF_CHARGE.value) return null + return AapDynamicEndOfChargeEvent(rawPayload = message.payload.copyOf()) + } + private fun buildRenameMessage(name: String): ByteArray { // Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation. // Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts @@ -510,31 +544,89 @@ class DefaultAapDeviceProfile( ) + nameBytes } - private fun parseNullTerminatedStrings(data: ByteArray): List { - // Skip binary header until the first printable byte (device name always starts - // with a printable character). This skips the length/type prefix bytes. - var headerEnd = 0 - while (headerEnd < data.size) { - val b = data[headerEnd].toInt() and 0xFF - if (b in 0x20..0x7E) break - headerEnd++ + /** + * Heuristic binary-prefix skip: walk forward until we hit a printable byte. + * The real header schema is `[02 XX 00 04 00]` in every capture to date, but + * documenting that without an authoritative source would be guessing. The + * heuristic breaks only for device names that start with a non-printable + * byte \u2014 theoretically emoji names (UTF-8 first byte 0xF0-0xF4, outside the + * printable range) could misalign here. None observed in real captures so far. + */ + private fun skipBinaryHeader(data: ByteArray): Int { + var offset = 0 + while (offset < data.size) { + val b = data[offset].toInt() and 0xFF + if (b in 0x20..0x7E) return offset + offset++ } - if (headerEnd >= data.size) return emptyList() - - // Split on NUL bytes and decode each segment as UTF-8. - // This handles device names with non-ASCII characters (e.g. curly quotes - // in "Matthias\u2019s AirPods Pro") that the old ASCII-only scanner would break on. - val strings = mutableListOf() - var i = headerEnd - while (i < data.size) { - // Skip NUL separators - while (i < data.size && data[i] == 0x00.toByte()) i++ - if (i >= data.size) break - - val segStart = i - while (i < data.size && data[i] != 0x00.toByte()) i++ - strings.add(String(data, segStart, i - segStart, Charsets.UTF_8)) - } - return strings + return data.size } + + private fun parseDeviceInfoPayload(data: ByteArray): DeviceInfoSegments? { + var offset = skipBinaryHeader(data) + if (offset >= data.size) return null + + // Segments 0..10: NUL-delimited UTF-8 strings. UTF-8 means non-ASCII (e.g. + // curly quotes in "Matthias's AirPods Pro") decodes correctly. + val strings = mutableListOf() + while (strings.size < 11 && offset < data.size) { + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + if (offset >= data.size) break + val segStart = offset + while (offset < data.size && data[offset] != 0x00.toByte()) offset++ + strings.add(String(data, segStart, offset - segStart, Charsets.UTF_8)) + } + + // Skip the NUL that terminated segment 10 (if present) before the UUID blob. + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + + // Segments 11 and 12: fixed 17-byte UUIDs (NOT NUL-terminated, may contain 0x00). + // Best-effort: if payload is shorter, both UUIDs are null. + val leftUuid: ByteArray? = if (offset + UUID_LEN <= data.size) { + val slice = data.copyOfRange(offset, offset + UUID_LEN) + offset += UUID_LEN + slice + } else null + + val rightUuid: ByteArray? = if (offset + UUID_LEN <= data.size) { + val slice = data.copyOfRange(offset, offset + UUID_LEN) + offset += UUID_LEN + slice + } else null + + // Segments 13 and 14: NUL-delimited ASCII-decimal epoch seconds (e.g. "1697480211"). + val trailingStrings = mutableListOf() + while (trailingStrings.size < 2 && offset < data.size) { + while (offset < data.size && data[offset] == 0x00.toByte()) offset++ + if (offset >= data.size) break + val segStart = offset + while (offset < data.size && data[offset] != 0x00.toByte()) offset++ + trailingStrings.add(String(data, segStart, offset - segStart, Charsets.UTF_8)) + } + + val leftPaired = trailingStrings.getOrNull(0).parseEpochSecondsOrNull() + val rightPaired = trailingStrings.getOrNull(1).parseEpochSecondsOrNull() + + return DeviceInfoSegments( + strings = strings, + leftEarbudUuid = leftUuid, + rightEarbudUuid = rightUuid, + leftEarbudFirstPaired = leftPaired, + rightEarbudFirstPaired = rightPaired, + ) + } + + private fun String?.parseEpochSecondsOrNull(): java.time.Instant? { + if (this.isNullOrBlank()) return null + val seconds = this.toLongOrNull() ?: return null + return runCatching { java.time.Instant.ofEpochSecond(seconds) }.getOrNull() + } + + private data class DeviceInfoSegments( + val strings: List, + val leftEarbudUuid: ByteArray?, + val rightEarbudUuid: ByteArray?, + val leftEarbudFirstPaired: java.time.Instant?, + val rightEarbudFirstPaired: java.time.Instant?, + ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt new file mode 100644 index 00000000..f594bf30 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/cache/CachedDeviceStateMigrationTest.kt @@ -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" + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt index 94ae0023..00a4d8a9 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt index 28ffbdfa..0a35a677 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt @@ -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 ────────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 73404eed..28c2ea0c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -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 ────────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt index 98316467..be921f33 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt @@ -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 ────────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt index 7abe99da..3f0ea976 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index b7561e19..9bebdb10 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -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 ─────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt index 7262b75c..6a8a8fab 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt @@ -77,6 +77,50 @@ class HidTrackerTest : BaseTest() { val result = HidTracker.classify(payload) result.shouldBeInstanceOf() } + + @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() + // 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() + 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() + // 00 00 10 01 must not match + HidTracker.classify(hexToBytes("00 00 10 01 AA BB CC DD")) + .shouldBeInstanceOf() + } } // ── 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() + 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() diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt new file mode 100644 index 00000000..c6f3c218 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapControlIdTest.kt @@ -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 + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt index ee76afa8..6220c468 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt new file mode 100644 index 00000000..5c3be828 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageTypeTest.kt @@ -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 + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt new file mode 100644 index 00000000..217cbafa --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/AapPacketTest.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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 + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt index bfabdacf..a0913a34 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/BaseAapSessionTest.kt @@ -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) ──────── diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt new file mode 100644 index 00000000..532227ea --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfileDeviceInfoTest.kt @@ -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() + } +}