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
@@ -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 ──────────────────────────────────────────────