feat(aap): Decode unknown settings, extend DeviceInfo, fix UTF-8 parsing

- Add UnknownSetting catch-all for 9 unconfirmed H2+ setting IDs (0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E)

- Downgrade known non-settings commands (0x0000, 0x0002, 0x000C, 0x0017, 0x002B, 0x004E, 0x0055, 0x0057) from INFO to VERBOSE

- Add earbud serials and build number to DeviceInfo (parsed from 0x001D segments 8-10, shown in DeviceInfoCard, persisted in cache)

- Fix UTF-8 device name parsing (curly quotes etc. no longer break segment indices)

- Add [was: ...] to setting change logs for easier protocol diff analysis

- Label DeviceInfoDump segments 5-12 (firmwareVersionDup, protocolVersion, updaterAppId, earbudSerials, buildNumber, encryptedBlob, timestamp)
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent fd6a9667e2
commit 32ff7e5bdd
12 changed files with 331 additions and 36 deletions
@@ -21,16 +21,28 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
internal fun buildModelLabel(device: PodDevice): String? {
if (device.model == PodModel.UNKNOWN) return null
val modelNumber = device.deviceInfo?.modelNumber?.takeIf { it.isNotBlank() }
return if (modelNumber != null) "${device.model.label} ($modelNumber)" else device.model.label
}
@Composable
internal fun DeviceInfoCard(
deviceInfo: AapDeviceInfo?,
modelLabel: String?,
systemBluetoothName: String?,
connectionStateLabel: String?,
lastSeen: String?,
firstSeen: String?,
@@ -57,17 +69,25 @@ internal fun DeviceInfoCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (modelLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_model_label),
value = modelLabel,
)
}
if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) {
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoRow(
label = stringResource(R.string.device_settings_info_name_label),
label = stringResource(R.string.device_settings_info_bt_name_label),
value = deviceInfo.name,
modifier = Modifier.weight(1f),
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
)
if (canRename) {
IconButton(onClick = { showRenameDialog = true }) {
@@ -92,6 +112,31 @@ internal fun DeviceInfoCard(
value = deviceInfo.firmwareVersion,
)
}
if (!deviceInfo.buildNumber.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_build_label),
value = deviceInfo.buildNumber,
)
}
if (!deviceInfo.leftEarbudSerial.isNullOrBlank() || !deviceInfo.rightEarbudSerial.isNullOrBlank()) {
Row(modifier = Modifier.fillMaxWidth()) {
if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_left_serial_label),
value = deviceInfo.leftEarbudSerial,
modifier = Modifier.weight(1f),
)
}
if (!deviceInfo.rightEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_right_serial_label),
value = deviceInfo.rightEarbudSerial,
modifier = Modifier.weight(1f),
textAlign = if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) TextAlign.End else TextAlign.Start,
)
}
}
}
}
if (connectionStateLabel != null) {
InfoRow(
@@ -99,16 +144,24 @@ internal fun DeviceInfoCard(
value = connectionStateLabel,
)
}
if (lastSeen != null) {
if (lastSeen != null && firstSeen != null) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
modifier = Modifier.weight(1f),
)
}
if (firstSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_first_seen_label),
value = firstSeen,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
} else if (lastSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
)
}
}
@@ -116,7 +169,13 @@ internal fun DeviceInfoCard(
}
@Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
private fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start,
valueFontFamily: FontFamily? = null,
) {
Column(
modifier = modifier.padding(vertical = 2.dp),
) {
@@ -124,10 +183,15 @@ private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier)
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = textAlign,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
fontFamily = valueFontFamily,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -142,7 +206,12 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
buildNumber = "8454624",
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
@@ -152,7 +221,27 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
@Composable
@Preview2
private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
private fun DeviceInfoCardMismatchPreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "My AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
canRename = true,
)
}
@Composable
@Preview2
private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "AirPods Pro",
@@ -161,6 +250,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Disconnected",
lastSeen = "2 hours ago",
firstSeen = null,
@@ -173,6 +264,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = null,
modelLabel = "AirPods Pro 2",
systemBluetoothName = null,
connectionStateLabel = "Disconnected",
lastSeen = "Just now",
firstSeen = null,
@@ -23,6 +23,9 @@ data class CachedDeviceState(
@SerialName("deviceName") val deviceName: String? = null,
@SerialName("serialNumber") val serialNumber: String? = null,
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
@SerialName("buildNumber") val buildNumber: String? = null,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("lastSeenAt") val lastSeenAt: Instant,
) {
@@ -35,6 +38,9 @@ data class CachedDeviceState(
manufacturer = "",
serialNumber = serialNumber ?: "",
firmwareVersion = firmwareVersion ?: "",
leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial,
buildNumber = buildNumber,
)
}
@@ -48,6 +48,9 @@ fun PodDevice.toCachedState(
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
lastSeenAt = seenLastAt ?: now,
)
@@ -346,6 +346,14 @@ internal class AapConnection(
2 -> "manufacturer"
3 -> "serialNumber"
4 -> "firmwareVersion"
5 -> "firmwareVersionDup"
6 -> "protocolVersion"
7 -> "updaterAppId"
8 -> "leftEarbudSerial"
9 -> "rightEarbudSerial"
10 -> "buildNumber"
11 -> "encryptedBlob"
12 -> "timestamp"
else -> "unknown"
}
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
@@ -390,6 +398,8 @@ internal class AapConnection(
// Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) ->
val previous = _state.value.settings[key]
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
if (value is AapSetting.AncMode) {
@@ -398,7 +408,7 @@ internal class AapConnection(
if (isFirstAncMode || sinceLastCommand <= 3000L) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting: ${key.simpleName} = $value" }
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
// After our command, firmware may cycle through modes before settling.
// Schedule a verification: if settled mode != commanded mode, re-send once.
@@ -421,7 +431,7 @@ internal class AapConnection(
ancDebounceJob = connectionScope?.launch {
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" }
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
}
}
return
@@ -438,7 +448,7 @@ internal class AapConnection(
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
}
_state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" }
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
@@ -463,6 +473,59 @@ internal class AapConnection(
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
if (message.commandType == 0x0009 && message.payload.size >= 2) {
val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF
val boolHint = appleBoolHint(value)
val tailHex = if (message.payload.size > 2) {
message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
log(TAG, INFO) {
buildString {
append("Unhandled setting id=0x${"%02X".format(settingId)} ")
append("value=0x${"%02X".format(value)}")
boolHint?.let { append(" appleBool=$it") }
append(" payload=${message.payload.size}B")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
if (message.commandType == 0x000C && message.payload.size >= 6) {
val macRaw = formatMac(message.payload, reverse = false)
val macReversed = formatMac(message.payload, reverse = true)
val tailHex = if (message.payload.size > 6) {
message.payload.copyOfRange(6, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
buildString {
append("Known cmd=0x000C")
append(" payload=${message.payload.size}B")
append(" macRaw=$macRaw macReversed=$macReversed")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
// Known non-settings commands: log at VERBOSE, refresh lastMessageAt
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
}
return
}
log(TAG, INFO) {
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
}
@@ -479,6 +542,29 @@ internal class AapConnection(
companion object {
private val TAG = logTag("AapConnection")
// Non-settings commands observed in real sessions. Logged at VERBOSE instead of INFO
// to reduce noise, but still fully logged with payload hex for debug log analysis.
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
0x0000, // Handshake acknowledgment
0x0002, // Capability/feature table (H2+ only)
0x0017, // HID/service descriptors
0x002B, // Session metadata / event history
0x004E, // Unknown (all-zero payload)
0x0055, // Audio/session state
0x0057, // Connection lifecycle
)
private fun appleBoolHint(wireValue: Int): Boolean? = when (wireValue) {
0x01 -> true
0x02 -> false
else -> null
}
private fun formatMac(bytes: ByteArray, reverse: Boolean): String {
val indices = if (reverse) (5 downTo 0) else (0..5)
return indices.joinToString(":") { "%02X".format(bytes[it]) }
}
/**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the
* issue #173 engraving discovery logging. Not part of the production decode path — the
@@ -9,4 +9,7 @@ data class AapDeviceInfo(
val manufacturer: String,
val serialNumber: String,
val firmwareVersion: String,
val leftEarbudSerial: String? = null,
val rightEarbudSerial: String? = null,
val buildNumber: String? = null,
)
@@ -186,4 +186,17 @@ sealed class AapSetting {
) : AapSetting() {
enum class Pod { LEFT, RIGHT }
}
/**
* Catch-all for setting IDs whose semantics are unconfirmed.
* Decoded to keep lastMessageAt fresh and log via the normal "Setting:" path.
* Not exposed in UI. When a setting's purpose is confirmed, promote it to
* its own named subclass.
*/
data class UnknownSetting(
val settingId: Int,
val rawValue: Int,
) : AapSetting() {
override fun toString(): String = "UnknownSetting(settingId=0x%02X, rawValue=0x%02X)".format(settingId, rawValue)
}
}
@@ -50,6 +50,12 @@ class DefaultAapDeviceProfile(
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,
)
// Command types for non-settings messages
const val CMD_RENAME = 0x001E
const val CMD_STEM_PRESS = 0x0019
@@ -269,6 +275,12 @@ class DefaultAapDeviceProfile(
val enabled = decodeAppleBool(value) ?: return null
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
}
in UNCONFIRMED_SETTING_IDS -> {
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
settingId = settingId,
rawValue = value,
)
}
else -> null
}
}
@@ -362,6 +374,12 @@ class DefaultAapDeviceProfile(
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() },
)
}
@@ -493,20 +511,29 @@ class DefaultAapDeviceProfile(
}
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
val strings = mutableListOf<String>()
var start = 0
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
// Header bytes and non-ASCII bytes are skipped.
var i = 0
while (i < data.size) {
val b = data[i].toInt() and 0xFF
if (b in 0x20..0x7E) {
start = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, start, i - start, Charsets.US_ASCII))
// 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++
}
i++
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<String>()
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
}
+16 -3
View File
@@ -10,6 +10,7 @@
<string name="general_donate_action">Donate</string>
<string name="general_check_action">Check</string>
<string name="general_close_action">Close</string>
<string name="general_edit_action">Edit</string>
<string name="general_save_action">Save</string>
<string name="general_guide_action">Guide</string>
<string name="general_continue_action">Continue</string>
@@ -438,9 +439,15 @@
<!-- Device Settings -->
<string name="device_settings_title">Device Settings</string>
<string name="device_settings_subtitle_profile_prefix">Profile: %s</string>
<string name="device_settings_info_name_label">Name</string>
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
<string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_build_label">Build</string>
<string name="device_settings_info_left_serial_label">Left Serial</string>
<string name="device_settings_info_right_serial_label">Right Serial</string>
<string name="device_settings_info_status_label">Status</string>
<string name="device_settings_info_last_seen_label">Last Seen</string>
<string name="device_settings_info_first_seen_label">First Seen</string>
@@ -495,13 +502,18 @@
<string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string>
<string name="device_settings_listening_mode_cycle_label">Noise Control Cycle</string>
<string name="device_settings_listening_mode_cycle_description">Select which modes cycle when pressing and holding the stem</string>
<string name="device_settings_noise_control_current_mode_label">Current mode</string>
<string name="device_settings_listening_mode_cycle_label">Long-press cycle</string>
<string name="device_settings_listening_mode_cycle_description">Choose which modes are available when holding the stem and in the quick switcher.</string>
<string name="device_settings_listening_mode_cycle_dialog_title">Customize Long-Press Cycle</string>
<string name="device_settings_listening_mode_cycle_summary_helper">Used when holding the stem and in the quick switcher</string>
<string name="device_settings_listening_mode_cycle_summary_helper_override">Used in the quick switcher. A custom stem long-press action overrides AirPods cycling.</string>
<string name="device_settings_listening_mode_cycle_minimum">At least 2 modes must stay enabled.</string>
<string name="device_settings_listening_mode_cycle_off">Off</string>
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string>
<string name="device_settings_listening_mode_cycle_transparency">Transparency</string>
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string>
<string name="device_settings_allow_off_label">Include Off in Noise Control</string>
<string name="device_settings_allow_off_label">Include Off</string>
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
@@ -542,5 +554,6 @@
<string name="stem_action_volume_down">Volume Down</string>
<string name="stem_actions_reset_label">Reset to defaults</string>
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
<string name="device_settings_noise_control_open_stem_actions_action">Open Stem Actions</string>
</resources>
@@ -572,4 +572,32 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
@Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() }
@Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() }
// ── DeviceInfo UTF-8 ────────────────────────────────────
@Test
fun `decodeDeviceInfo handles curly quote in device name`() {
// "Matthias\u2019s AirPods Pro" — curly right single quote is UTF-8 E2 80 99.
// The old ASCII-only parser would split at these bytes, breaking all segment indices.
// Header bytes must all be < 0x20 to avoid being mistaken for string content.
val msg = aapMessage(
"04 00 04 00 1D 00 02 0F 00 04 00",
// [0] name: "Matthias\u2019s AirPods Pro" (UTF-8)
"4D 61 74 74 68 69 61 73 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F 00",
// [1] model
"41 33 30 34 38 00",
// [2] manufacturer
"41 70 70 6C 65 20 49 6E 63 2E 00",
// [3] serial
"57 35 4A 37 4B 56 30 4E 30 34 00",
// [4] firmware
"37 41 33 30 35 00",
)
val info = profile.decodeDeviceInfo(msg)!!
info.name shouldBe "Matthias\u2019s AirPods Pro"
info.modelNumber shouldBe "A3048"
info.manufacturer shouldBe "Apple Inc."
info.serialNumber shouldBe "W5J7KV0N04"
info.firmwareVersion shouldBe "7A305"
}
}
@@ -56,6 +56,9 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro"
info.modelNumber shouldBe "A3048"
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "H3KL7HR926JY"
info.rightEarbudSerial shouldBe "H3KL2AYL26K0"
info.buildNumber shouldBe "8454480"
}
// ── Battery ──────────────────────────────────────────────
@@ -242,11 +245,18 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
}
@Test
fun `unknown settings IDs return null`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33)
for (id in unknownIds) {
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull()
fun `unconfirmed settings IDs decode as UnknownSetting`() {
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33)
for (id in unconfirmedIds) {
val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x02))
setting.settingId shouldBe id
setting.rawValue shouldBe 0x02
}
}
@Test
fun `truly unknown setting ID returns null`() {
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
}
}
}
@@ -56,6 +56,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro 3"
info.modelNumber shouldBe "A3064"
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ"
info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY"
info.buildNumber shouldBe "8454624"
}
// ── Battery ──────────────────────────────────────────────
@@ -236,11 +239,18 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
}
@Test
fun `unknown settings IDs return null`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
for (id in unknownIds) {
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
fun `unconfirmed settings IDs decode as UnknownSetting`() {
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
for (id in unconfirmedIds) {
val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x01))
setting.settingId shouldBe id
setting.rawValue shouldBe 0x01
}
}
@Test
fun `truly unknown setting ID returns null`() {
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
}
}
}
@@ -56,6 +56,9 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
info.name shouldBe "AirPods Pro"
info.modelNumber shouldBe "A2084"
info.manufacturer shouldBe "Apple Inc."
info.leftEarbudSerial shouldBe "GXDDRFNW0C6K"
info.rightEarbudSerial shouldBe "H6RHL0HF0C6J"
info.buildNumber shouldBe "3344646"
}
// ── Battery ──────────────────────────────────────────────