mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -21,16 +21,28 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog
|
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
|
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
|
@Composable
|
||||||
internal fun DeviceInfoCard(
|
internal fun DeviceInfoCard(
|
||||||
deviceInfo: AapDeviceInfo?,
|
deviceInfo: AapDeviceInfo?,
|
||||||
|
modelLabel: String?,
|
||||||
|
systemBluetoothName: String?,
|
||||||
connectionStateLabel: String?,
|
connectionStateLabel: String?,
|
||||||
lastSeen: String?,
|
lastSeen: String?,
|
||||||
firstSeen: String?,
|
firstSeen: String?,
|
||||||
@@ -57,17 +69,25 @@ internal fun DeviceInfoCard(
|
|||||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.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 != null) {
|
||||||
if (deviceInfo.name.isNotBlank()) {
|
if (deviceInfo.name.isNotBlank()) {
|
||||||
|
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.device_settings_info_name_label),
|
label = stringResource(R.string.device_settings_info_bt_name_label),
|
||||||
value = deviceInfo.name,
|
value = deviceInfo.name,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
|
||||||
)
|
)
|
||||||
if (canRename) {
|
if (canRename) {
|
||||||
IconButton(onClick = { showRenameDialog = true }) {
|
IconButton(onClick = { showRenameDialog = true }) {
|
||||||
@@ -92,6 +112,31 @@ internal fun DeviceInfoCard(
|
|||||||
value = deviceInfo.firmwareVersion,
|
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) {
|
if (connectionStateLabel != null) {
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -99,16 +144,24 @@ internal fun DeviceInfoCard(
|
|||||||
value = connectionStateLabel,
|
value = connectionStateLabel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (lastSeen != null) {
|
if (lastSeen != null && firstSeen != null) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.device_settings_info_last_seen_label),
|
label = stringResource(R.string.device_settings_info_last_seen_label),
|
||||||
value = lastSeen,
|
value = lastSeen,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (firstSeen != null) {
|
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.device_settings_info_first_seen_label),
|
label = stringResource(R.string.device_settings_info_first_seen_label),
|
||||||
value = firstSeen,
|
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
|
@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(
|
Column(
|
||||||
modifier = modifier.padding(vertical = 2.dp),
|
modifier = modifier.padding(vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
@@ -124,10 +183,15 @@ private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier)
|
|||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = textAlign,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = textAlign,
|
||||||
|
fontFamily = valueFontFamily,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +206,12 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
|||||||
manufacturer = "Apple Inc.",
|
manufacturer = "Apple Inc.",
|
||||||
serialNumber = "W5J7KV0N04",
|
serialNumber = "W5J7KV0N04",
|
||||||
firmwareVersion = "7A305",
|
firmwareVersion = "7A305",
|
||||||
|
leftEarbudSerial = "H3KL7HR926JY",
|
||||||
|
rightEarbudSerial = "H3KL2AYL26K0",
|
||||||
|
buildNumber = "8454624",
|
||||||
),
|
),
|
||||||
|
modelLabel = "AirPods Pro 2 (A2699)",
|
||||||
|
systemBluetoothName = "AirPods Pro",
|
||||||
connectionStateLabel = "Connected",
|
connectionStateLabel = "Connected",
|
||||||
lastSeen = "Just now",
|
lastSeen = "Just now",
|
||||||
firstSeen = "5 minutes ago",
|
firstSeen = "5 minutes ago",
|
||||||
@@ -152,7 +221,27 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview2
|
@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(
|
DeviceInfoCard(
|
||||||
deviceInfo = AapDeviceInfo(
|
deviceInfo = AapDeviceInfo(
|
||||||
name = "AirPods Pro",
|
name = "AirPods Pro",
|
||||||
@@ -161,6 +250,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
|
|||||||
serialNumber = "W5J7KV0N04",
|
serialNumber = "W5J7KV0N04",
|
||||||
firmwareVersion = "7A305",
|
firmwareVersion = "7A305",
|
||||||
),
|
),
|
||||||
|
modelLabel = "AirPods Pro 2 (A2699)",
|
||||||
|
systemBluetoothName = "AirPods Pro",
|
||||||
connectionStateLabel = "Disconnected",
|
connectionStateLabel = "Disconnected",
|
||||||
lastSeen = "2 hours ago",
|
lastSeen = "2 hours ago",
|
||||||
firstSeen = null,
|
firstSeen = null,
|
||||||
@@ -173,6 +264,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
|
|||||||
private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
|
private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
|
||||||
DeviceInfoCard(
|
DeviceInfoCard(
|
||||||
deviceInfo = null,
|
deviceInfo = null,
|
||||||
|
modelLabel = "AirPods Pro 2",
|
||||||
|
systemBluetoothName = null,
|
||||||
connectionStateLabel = "Disconnected",
|
connectionStateLabel = "Disconnected",
|
||||||
lastSeen = "Just now",
|
lastSeen = "Just now",
|
||||||
firstSeen = null,
|
firstSeen = null,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ data class CachedDeviceState(
|
|||||||
@SerialName("deviceName") val deviceName: String? = null,
|
@SerialName("deviceName") val deviceName: String? = null,
|
||||||
@SerialName("serialNumber") val serialNumber: String? = null,
|
@SerialName("serialNumber") val serialNumber: String? = null,
|
||||||
@SerialName("firmwareVersion") val firmwareVersion: 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)
|
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||||
) {
|
) {
|
||||||
@@ -35,6 +38,9 @@ data class CachedDeviceState(
|
|||||||
manufacturer = "",
|
manufacturer = "",
|
||||||
serialNumber = serialNumber ?: "",
|
serialNumber = serialNumber ?: "",
|
||||||
firmwareVersion = firmwareVersion ?: "",
|
firmwareVersion = firmwareVersion ?: "",
|
||||||
|
leftEarbudSerial = leftEarbudSerial,
|
||||||
|
rightEarbudSerial = rightEarbudSerial,
|
||||||
|
buildNumber = buildNumber,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
@@ -48,6 +48,9 @@ fun PodDevice.toCachedState(
|
|||||||
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
|
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
|
||||||
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
|
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
|
||||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||||
|
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
|
||||||
|
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
|
||||||
|
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
|
||||||
lastSeenAt = seenLastAt ?: now,
|
lastSeenAt = seenLastAt ?: now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,14 @@ internal class AapConnection(
|
|||||||
2 -> "manufacturer"
|
2 -> "manufacturer"
|
||||||
3 -> "serialNumber"
|
3 -> "serialNumber"
|
||||||
4 -> "firmwareVersion"
|
4 -> "firmwareVersion"
|
||||||
|
5 -> "firmwareVersionDup"
|
||||||
|
6 -> "protocolVersion"
|
||||||
|
7 -> "updaterAppId"
|
||||||
|
8 -> "leftEarbudSerial"
|
||||||
|
9 -> "rightEarbudSerial"
|
||||||
|
10 -> "buildNumber"
|
||||||
|
11 -> "encryptedBlob"
|
||||||
|
12 -> "timestamp"
|
||||||
else -> "unknown"
|
else -> "unknown"
|
||||||
}
|
}
|
||||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||||
@@ -390,6 +398,8 @@ internal class AapConnection(
|
|||||||
|
|
||||||
// Try setting update (merge into existing state)
|
// Try setting update (merge into existing state)
|
||||||
profile.decodeSetting(message)?.let { (key, value) ->
|
profile.decodeSetting(message)?.let { (key, value) ->
|
||||||
|
val previous = _state.value.settings[key]
|
||||||
|
|
||||||
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
|
// 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.
|
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
|
||||||
if (value is AapSetting.AncMode) {
|
if (value is AapSetting.AncMode) {
|
||||||
@@ -398,7 +408,7 @@ internal class AapConnection(
|
|||||||
if (isFirstAncMode || sinceLastCommand <= 3000L) {
|
if (isFirstAncMode || sinceLastCommand <= 3000L) {
|
||||||
ancDebounceJob?.cancel()
|
ancDebounceJob?.cancel()
|
||||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
_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.
|
// After our command, firmware may cycle through modes before settling.
|
||||||
// Schedule a verification: if settled mode != commanded mode, re-send once.
|
// Schedule a verification: if settled mode != commanded mode, re-send once.
|
||||||
@@ -421,7 +431,7 @@ internal class AapConnection(
|
|||||||
ancDebounceJob = connectionScope?.launch {
|
ancDebounceJob = connectionScope?.launch {
|
||||||
delay(1500L)
|
delay(1500L)
|
||||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
_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
|
return
|
||||||
@@ -438,7 +448,7 @@ internal class AapConnection(
|
|||||||
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
|
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
|
||||||
}
|
}
|
||||||
_state.value = newState
|
_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
|
// Flush queued ANC command when a pod goes in ear
|
||||||
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
|
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
|
||||||
@@ -463,6 +473,59 @@ internal class AapConnection(
|
|||||||
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
||||||
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
|
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
|
||||||
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
|
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) {
|
log(TAG, INFO) {
|
||||||
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
|
"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 {
|
companion object {
|
||||||
private val TAG = logTag("AapConnection")
|
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
|
* 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
|
* issue #173 engraving discovery logging. Not part of the production decode path — the
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ data class AapDeviceInfo(
|
|||||||
val manufacturer: String,
|
val manufacturer: String,
|
||||||
val serialNumber: String,
|
val serialNumber: String,
|
||||||
val firmwareVersion: String,
|
val firmwareVersion: String,
|
||||||
|
val leftEarbudSerial: String? = null,
|
||||||
|
val rightEarbudSerial: String? = null,
|
||||||
|
val buildNumber: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,4 +186,17 @@ sealed class AapSetting {
|
|||||||
) : AapSetting() {
|
) : AapSetting() {
|
||||||
enum class Pod { LEFT, RIGHT }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-13
@@ -50,6 +50,12 @@ class DefaultAapDeviceProfile(
|
|||||||
const val SETTING_SLEEP_DETECTION = 0x35
|
const val SETTING_SLEEP_DETECTION = 0x35
|
||||||
const val SETTING_STEM_CONFIG = 0x39
|
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
|
// Command types for non-settings messages
|
||||||
const val CMD_RENAME = 0x001E
|
const val CMD_RENAME = 0x001E
|
||||||
const val CMD_STEM_PRESS = 0x0019
|
const val CMD_STEM_PRESS = 0x0019
|
||||||
@@ -269,6 +275,12 @@ class DefaultAapDeviceProfile(
|
|||||||
val enabled = decodeAppleBool(value) ?: return null
|
val enabled = decodeAppleBool(value) ?: return null
|
||||||
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
|
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
|
||||||
}
|
}
|
||||||
|
in UNCONFIRMED_SETTING_IDS -> {
|
||||||
|
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
|
||||||
|
settingId = settingId,
|
||||||
|
rawValue = value,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,6 +374,12 @@ class DefaultAapDeviceProfile(
|
|||||||
manufacturer = strings.getOrElse(2) { "" },
|
manufacturer = strings.getOrElse(2) { "" },
|
||||||
serialNumber = strings.getOrElse(3) { "" },
|
serialNumber = strings.getOrElse(3) { "" },
|
||||||
firmwareVersion = strings.getOrElse(4) { "" },
|
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> {
|
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
|
||||||
val strings = mutableListOf<String>()
|
// Skip binary header until the first printable byte (device name always starts
|
||||||
var start = 0
|
// with a printable character). This skips the length/type prefix bytes.
|
||||||
|
var headerEnd = 0
|
||||||
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
|
while (headerEnd < data.size) {
|
||||||
// Header bytes and non-ASCII bytes are skipped.
|
val b = data[headerEnd].toInt() and 0xFF
|
||||||
var i = 0
|
if (b in 0x20..0x7E) break
|
||||||
while (i < data.size) {
|
headerEnd++
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
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
|
return strings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<string name="general_donate_action">Donate</string>
|
<string name="general_donate_action">Donate</string>
|
||||||
<string name="general_check_action">Check</string>
|
<string name="general_check_action">Check</string>
|
||||||
<string name="general_close_action">Close</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_save_action">Save</string>
|
||||||
<string name="general_guide_action">Guide</string>
|
<string name="general_guide_action">Guide</string>
|
||||||
<string name="general_continue_action">Continue</string>
|
<string name="general_continue_action">Continue</string>
|
||||||
@@ -438,9 +439,15 @@
|
|||||||
|
|
||||||
<!-- Device Settings -->
|
<!-- Device Settings -->
|
||||||
<string name="device_settings_title">Device Settings</string>
|
<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_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_serial_label">Serial Number</string>
|
||||||
<string name="device_settings_info_firmware_label">Firmware</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_status_label">Status</string>
|
||||||
<string name="device_settings_info_last_seen_label">Last Seen</string>
|
<string name="device_settings_info_last_seen_label">Last Seen</string>
|
||||||
<string name="device_settings_info_first_seen_label">First 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_auto">Auto</string>
|
||||||
<string name="device_settings_microphone_mode_right">Right</string>
|
<string name="device_settings_microphone_mode_right">Right</string>
|
||||||
<string name="device_settings_microphone_mode_left">Left</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_noise_control_current_mode_label">Current mode</string>
|
||||||
<string name="device_settings_listening_mode_cycle_description">Select which modes cycle when pressing and holding the stem</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_off">Off</string>
|
||||||
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</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_transparency">Transparency</string>
|
||||||
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</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_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_label">Sleep Detection</string>
|
||||||
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</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_action_volume_down">Volume Down</string>
|
||||||
<string name="stem_actions_reset_label">Reset to defaults</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="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>
|
</resources>
|
||||||
|
|||||||
+28
@@ -572,4 +572,32 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
|||||||
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
|
@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 `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() }
|
@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -56,6 +56,9 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
|||||||
info.name shouldBe "AirPods Pro"
|
info.name shouldBe "AirPods Pro"
|
||||||
info.modelNumber shouldBe "A3048"
|
info.modelNumber shouldBe "A3048"
|
||||||
info.manufacturer shouldBe "Apple Inc."
|
info.manufacturer shouldBe "Apple Inc."
|
||||||
|
info.leftEarbudSerial shouldBe "H3KL7HR926JY"
|
||||||
|
info.rightEarbudSerial shouldBe "H3KL2AYL26K0"
|
||||||
|
info.buildNumber shouldBe "8454480"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Battery ──────────────────────────────────────────────
|
// ── Battery ──────────────────────────────────────────────
|
||||||
@@ -242,11 +245,18 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `unknown settings IDs return null`() {
|
fun `unconfirmed settings IDs decode as UnknownSetting`() {
|
||||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33)
|
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33)
|
||||||
for (id in unknownIds) {
|
for (id in unconfirmedIds) {
|
||||||
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -56,6 +56,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
|||||||
info.name shouldBe "AirPods Pro 3"
|
info.name shouldBe "AirPods Pro 3"
|
||||||
info.modelNumber shouldBe "A3064"
|
info.modelNumber shouldBe "A3064"
|
||||||
info.manufacturer shouldBe "Apple Inc."
|
info.manufacturer shouldBe "Apple Inc."
|
||||||
|
info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ"
|
||||||
|
info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY"
|
||||||
|
info.buildNumber shouldBe "8454624"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Battery ──────────────────────────────────────────────
|
// ── Battery ──────────────────────────────────────────────
|
||||||
@@ -236,11 +239,18 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `unknown settings IDs return null`() {
|
fun `unconfirmed settings IDs decode as UnknownSetting`() {
|
||||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
|
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
|
||||||
for (id in unknownIds) {
|
for (id in unconfirmedIds) {
|
||||||
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -56,6 +56,9 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
|
|||||||
info.name shouldBe "AirPods Pro"
|
info.name shouldBe "AirPods Pro"
|
||||||
info.modelNumber shouldBe "A2084"
|
info.modelNumber shouldBe "A2084"
|
||||||
info.manufacturer shouldBe "Apple Inc."
|
info.manufacturer shouldBe "Apple Inc."
|
||||||
|
info.leftEarbudSerial shouldBe "GXDDRFNW0C6K"
|
||||||
|
info.rightEarbudSerial shouldBe "H6RHL0HF0C6J"
|
||||||
|
info.buildNumber shouldBe "3344646"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Battery ──────────────────────────────────────────────
|
// ── Battery ──────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user