feat(aap): Add structured HID descriptor logging with batched summaries

During case transitions, AirPods send 800+ cmd 0x0017 HID frames in ~20s. Previously each logged identically at VERBOSE with raw hex (~160KB noise). Now a HidTracker classifies frames (service directory, descriptor bulk, terminator) and batches consecutive bulk frames by (phase, fill), emitting 3-4 summary lines instead of 822.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 4ba59636c0
commit 789c23d24c
3 changed files with 443 additions and 3 deletions
@@ -445,4 +445,85 @@ class AapSessionEngineTest : BaseTest() {
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
}
}
// ── HID descriptor handling ─────────────────────────────
@Nested
inner class HidDescriptorTests {
private fun hidMessage(payload: ByteArray): AapMessage {
val header = byteArrayOf(0x04, 0x00, (payload.size and 0xFF).toByte(), ((payload.size shr 8) and 0xFF).toByte())
val cmdBytes = byteArrayOf(0x17, 0x00)
val raw = header + cmdBytes + payload
return AapMessage(raw = raw, commandType = 0x0017, payload = payload)
}
private fun hexToBytes(hex: String): ByteArray =
hex.split(" ").filter { it.isNotBlank() }.map { it.toInt(16).toByte() }.toByteArray()
@Test
fun `0x0017 during HANDSHAKING triggers READY transition`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
engine.processMessage(hidMessage(hexToBytes("00 04 00 00 01 00 FF")))
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.READY
}
@Test
fun `0x0017 refreshes lastMessageAt`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.startReady(scope)
engine.state.value.lastMessageAt.shouldNotBeNull()
val before = engine.state.value.lastMessageAt
every { timeSource.now() } returns Instant.ofEpochMilli(5000L)
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
engine.processMessage(hidMessage(descriptor))
engine.state.value.lastMessageAt shouldBe Instant.ofEpochMilli(5000L)
}
@Test
fun `malformed 0x0017 payload does not throw`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.startReady(scope)
engine.processMessage(hidMessage(ByteArray(0)))
engine.processMessage(hidMessage(hexToBytes("AB CD")))
engine.processMessage(hidMessage(hexToBytes("00 04 00 00 44 00")))
}
@Test
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile {
every { decodeStemPress(any()) } returns StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT)
}
val engine = createEngine(profile)
engine.startReady(this as TestScope)
val collected = mutableListOf<StemPressEvent>()
val collector = launch { engine.stemPressEvents.collect { collected.add(it) } }
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
engine.processMessage(hidMessage(descriptor))
runCurrent()
// If 0x0017 went through the decode pipeline, decodeStemPress would fire
// and emit a StemPressEvent. The fast-path should bypass all decode calls.
collected.shouldBeEmpty()
collector.cancel()
}
}
}
@@ -0,0 +1,214 @@
package eu.darken.capod.pods.core.apple.aap
import eu.darken.capod.pods.core.apple.aap.HidTracker.HidFrameType
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class HidTrackerTest : BaseTest() {
private fun hexToBytes(hex: String): ByteArray =
hex.split(" ").filter { it.isNotBlank() }.map { it.toInt(16).toByte() }.toByteArray()
// ── Classification ─────────────────────────────────────
@Nested
inner class ClassifyTests {
@Test
fun `service directory frame from Pro 3 capture`() {
val payload = hexToBytes(
"FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " +
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
)
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.ServiceDirectory>()
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
}
@Test
fun `descriptor bulk frame phase 0x81`() {
val fill = ByteArray(65) { 0xFF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.phase shouldBe 0x81
result.fill shouldBe 0xFF
}
@Test
fun `descriptor bulk frame phase 0x02`() {
val fill = ByteArray(65) { 0xEF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.phase shouldBe 0x02
result.fill shouldBe 0xEF
}
@Test
fun `terminator frame`() {
val payload = hexToBytes("00 04 00 00 01 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Terminator>()
result.payloadSize shouldBe 7
}
@Test
fun `short frame ending in FF but not 7 bytes is Other`() {
val payload = hexToBytes("00 04 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
}
@Test
fun `empty payload is Other`() {
val result = HidTracker.classify(ByteArray(0))
result.shouldBeInstanceOf<HidFrameType.Other>()
}
@Test
fun `random payload is Other`() {
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
}
}
// ── Batching ───────────────────────────────────────────
@Nested
inner class BatchingTests {
@Test
fun `same phase and fill batches together`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill = ByteArray(65) { 0xFF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
tracker.consume(payload)
tracker.consume(payload)
tracker.consume(payload)
logs.shouldBeEmpty()
tracker.flush()
logs.size shouldBe 1
logs[0] shouldBe "HID: 3 descriptor frames phase=0x81 fill=0xFF"
}
@Test
fun `phase change flushes previous batch`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill81 = ByteArray(65) { 0xFF.toByte() }
val payload81 = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill81
val fill02 = ByteArray(65) { 0xEF.toByte() }
val payload02 = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill02
tracker.consume(payload81)
tracker.consume(payload81)
tracker.consume(payload02)
logs.size shouldBe 1
logs[0] shouldBe "HID: 2 descriptor frames phase=0x81 fill=0xFF"
tracker.flush()
logs.size shouldBe 2
logs[1] shouldBe "HID: 1 descriptor frames phase=0x02 fill=0xEF"
}
@Test
fun `same phase but different fill creates separate batch`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fillFF = ByteArray(65) { 0xFF.toByte() }
val payloadFF = hexToBytes("00 04 00 00 44 00 01 A1 81") + fillFF
val fillEF = ByteArray(65) { 0xEF.toByte() }
val payloadEF = hexToBytes("00 04 00 00 44 00 01 C3 81") + fillEF
tracker.consume(payloadFF)
tracker.consume(payloadEF)
logs.size shouldBe 1
logs[0] shouldBe "HID: 1 descriptor frames phase=0x81 fill=0xFF"
tracker.flush()
logs.size shouldBe 2
logs[1] shouldBe "HID: 1 descriptor frames phase=0x81 fill=0xEF"
}
@Test
fun `service directory flushes pending batch`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val directory = hexToBytes(
"FE 00 00 02 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00"
)
tracker.consume(descriptor)
tracker.consume(descriptor)
tracker.consume(directory)
logs.size shouldBe 2
logs[0] shouldBe "HID: 2 descriptor frames phase=0x81 fill=0xFF"
logs[1] shouldBe "HID: services=[AP, AOP] (20B)"
}
@Test
fun `terminator flushes pending batch`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val terminator = hexToBytes("00 04 00 00 01 00 FF")
tracker.consume(descriptor)
tracker.consume(terminator)
logs.size shouldBe 2
logs[0] shouldBe "HID: 1 descriptor frames phase=0x81 fill=0xFF"
logs[1] shouldBe "HID: terminator (7B)"
}
@Test
fun `flush with zero count is no-op`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
tracker.flush()
logs.shouldBeEmpty()
}
@Test
fun `reset clears pending batch without logging`() {
val logs = mutableListOf<String>()
val tracker = HidTracker { logs.add(it) }
val fill = ByteArray(65) { 0xFF.toByte() }
val descriptor = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
tracker.consume(descriptor)
tracker.consume(descriptor)
tracker.reset()
tracker.flush()
logs.shouldBeEmpty()
}
}
}