feat(aap): Add Custom EQ protocol support

Adds decode and encode for AAP opcode 0x63, Apple's iOS 27 "Custom EQ"
(three bands plus a Recommended/Custom mode selector, H2 models).

The wire format comes from librepods commit 7341e41 and has never been
confirmed on hardware, so the decoder validates every field and returns
null on any mismatch -- including a strict payload-length equality, so a
frame carrying trailing unknown bytes is surfaced verbatim in the
unknown-message hex log rather than mis-parsed into plausible values.
Nothing optimistically assumes the write lands: SetCustomEq deliberately
has no optimistic state update and no verification predicate, because an
unacknowledged write must not render as success, and the device is not
known to report this setting at all.

Also recovers two bytes the 0x53 decoder was discarding. Opcode 0x53 is
the Headphone Accommodations configuration -- an iOS Accessibility
feature that the iOS 18.1+ hearing-aid feature reuses -- and payload
bytes 4 and 5 are its "Apply To: Phone / Media" flags. They are plain
0x01 flags, not the Apple-bool 0x01/0x02 encoding used by the 0x09
control settings. They describe scope only and say nothing about whether
a profile exists, so isAllZero stays a pure band-data predicate.

A debug-only card (BuildConfig.DEBUG, mirroring the existing PME chart)
drives the opcode for hardware evaluation. It edits local draft state and
sends exactly one packet per Apply tap; slider-driven sends would flood
the link and make a logcat observation window unreadable. Apply is
disabled while no pod is worn, because AapOutboundController queues
non-exempt commands in that state and the queue collapses repeats,
surfacing them later at an unrelated moment. That guard reads the AAP
EarDetection setting rather than PodDevice's BLE-fallback property, so it
matches the controller's gate in every state -- notably, neither gates
when the AAP setting is absent.

On-device evaluation, both legs negative: AirPods Pro 2 USB-C (A3048,
fw 81.2675000075000000.6814) and AirPods Pro 3 (A3064, fw ...6503)
never advertise 0x63 across settings bursts of 33 and 26 frames, never
echo a write, never persist one across a case-cycle reconnect, and
produce no audible change. Both accept the write at the socket and
discard it. This does not establish that the byte format is wrong -- a
device ignoring an unimplemented opcode is indistinguishable from one
rejecting a malformed frame -- and it is not evidence that privileged
access is required, since neither device advertises the feature at all.
This commit is contained in:
darken
2026-08-21 15:23:32 +02:00
parent b07428f894
commit bd4d87c06a
13 changed files with 742 additions and 17 deletions
@@ -46,6 +46,7 @@ import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.CustomEqDebugCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
@@ -190,6 +191,7 @@ fun DeviceSettingsScreenHost(
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
onCustomEqApply = { mode, low, mid, high -> vm.setCustomEq(mode, low, mid, high) },
)
}
@@ -235,6 +237,7 @@ fun DeviceSettingsScreen(
onOpenAapTracker: () -> Unit = {},
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
onResetBatteryEstimate: () -> Unit = {},
onCustomEqApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> },
) {
val device = state.device
val features = device?.model?.features
@@ -518,6 +521,20 @@ fun DeviceSettingsScreen(
}
}
}
// Custom EQ evaluation control (debug only, opcode 0x63). The 0x63 wire format
// has never been confirmed on hardware, so this exists to find out whether a real
// device accepts it. Deliberately ungated by capability or model — gating on an
// unknown capability bit would defeat the test.
if (eu.darken.capod.BuildConfig.DEBUG) {
item("custom_eq_debug_section") {
CustomEqDebugCard(
device = device,
enabled = enabled,
onApply = onCustomEqApply,
)
}
}
}
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
@@ -347,6 +347,14 @@ class DeviceSettingsViewModel @Inject constructor(
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
/**
* Debug-only evaluation control (see `CustomEqDebugCard`). One tap sends exactly one packet
* carrying the complete tuple — the 0x63 format is unconfirmed and the point is to observe
* how a real device answers a single write.
*/
fun setCustomEq(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int) =
send(AapCommand.SetCustomEq(mode, low, mid, high))
fun setDeviceName(name: String) = launch {
val address = currentAddress() ?: return@launch
try {
@@ -0,0 +1,214 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.GraphicEq
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.main.ui.devicesettings.components.SegmentedSettingRow
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Debug-only evaluation control for Custom EQ (opcode 0x63), whose wire format has never been
* confirmed on hardware. It is a measurement instrument, not a shipped feature: the sliders and
* the mode toggle edit local draft state only, and exactly one packet leaves the device per
* Apply tap, so the logcat observation window stays readable.
*
* Every label below is hardcoded English on purpose. This card is throwaway instrumentation that
* only renders under [eu.darken.capod.BuildConfig.DEBUG]; routing its labels through the base
* locale would push a dozen strings to Crowdin and have translators work through them for every
* locale, for text no release build can ever show.
*/
@Composable
internal fun CustomEqDebugCard(
device: PodDevice,
enabled: Boolean,
onApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> },
) {
val reported = device.customEq
// AapOutboundController ear-gates every command except SetDeviceName and SetDynamicEndOfCharge:
// with no pod in ear the write is queued and only flushed on the next in-ear event, so the
// packet would surface minutes later at an unrelated moment and poison the logcat window this
// card exists to produce.
//
// The controller reads the AAP EarDetection setting alone and does not gate while that setting
// is absent, so this mirror has to gate on the same source: hasAapEarDetection makes
// isEitherPodInEar return the AAP value without ever falling back to the BLE ear bits, which
// phantom-report "in ear" for pods resting in the case.
val wouldQueue = device.hasAapEarDetection && device.isEitherPodInEar != true
var draftMode by remember(reported) {
mutableStateOf(reported?.mode ?: AapSetting.CustomEq.Mode.RECOMMENDED)
}
var draftLow by remember(reported) { mutableIntStateOf(reported?.low ?: NEUTRAL_BAND) }
var draftMid by remember(reported) { mutableIntStateOf(reported?.mid ?: NEUTRAL_BAND) }
var draftHigh by remember(reported) { mutableIntStateOf(reported?.high ?: NEUTRAL_BAND) }
SettingsSection(title = "Custom EQ") {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = "Reported by device",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (reported != null) {
bandsText(reported.mode, reported.low, reported.mid, reported.high)
} else {
"Not reported"
},
style = MaterialTheme.typography.bodyMedium,
)
}
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = "Draft to send",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = bandsText(draftMode, draftLow, draftMid, draftHigh),
style = MaterialTheme.typography.bodyMedium,
)
}
SegmentedSettingRow(
icon = Icons.TwoTone.GraphicEq,
title = "Mode",
options = AapSetting.CustomEq.Mode.entries.map { it.label to it },
selected = draftMode,
onSelected = { draftMode = it },
enabled = enabled,
)
BandSlider(
title = "Low",
value = draftLow,
onValueChange = { draftLow = it },
enabled = enabled,
)
BandSlider(
title = "Mid",
value = draftMid,
onValueChange = { draftMid = it },
enabled = enabled,
)
BandSlider(
title = "High",
value = draftHigh,
onValueChange = { draftHigh = it },
enabled = enabled,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (wouldQueue) {
Text(
text = NOT_IN_EAR_NOTICE,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
)
}
Button(
onClick = { onApply(draftMode, draftLow, draftMid, draftHigh) },
enabled = enabled && !wouldQueue,
) {
Text("Apply")
}
}
}
}
@Composable
private fun BandSlider(
title: String,
value: Int,
onValueChange: (Int) -> Unit,
enabled: Boolean,
) {
SettingsSliderItem(
icon = Icons.TwoTone.GraphicEq,
title = title,
value = value.toFloat(),
// Draft only — dispatching from here (or from onValueChangeFinished) would flood the link
// with intermediate tuples. The Apply button is the sole sender.
onValueChange = { onValueChange(it.toInt()) },
valueRange = 0f..100f,
steps = 99,
enabled = enabled,
valueLabel = { it.toInt().toString() },
)
}
private const val NEUTRAL_BAND = 50
internal const val NOT_IN_EAR_NOTICE =
"No pod in ear, a write would be queued instead of sent. Wear a pod before applying."
private fun bandsText(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int): String =
"${mode.label} · low $low / mid $mid / high $high"
private val AapSetting.CustomEq.Mode.label: String
get() = when (this) {
AapSetting.CustomEq.Mode.RECOMMENDED -> "Recommended"
AapSetting.CustomEq.Mode.CUSTOM -> "Custom"
}
@Preview2
@Composable
private fun CustomEqDebugCardNotReportedPreview() = PreviewWrapper {
CustomEqDebugCard(
device = previewFullState(isPro = true).device!!,
enabled = true,
)
}
@Preview2
@Composable
private fun CustomEqDebugCardReportedPreview() = PreviewWrapper {
val device = previewFullState(isPro = true).device!!
CustomEqDebugCard(
device = device.copy(
aap = device.aap!!.withSetting(
AapSetting.CustomEq::class,
AapSetting.CustomEq(
mode = AapSetting.CustomEq.Mode.CUSTOM,
low = 60,
mid = 50,
high = 35,
),
),
),
enabled = true,
)
}
@@ -408,6 +408,9 @@ data class PodDevice(
val pmeConfig: AapSetting.PmeConfig?
get() = aap?.setting()
val customEq: AapSetting.CustomEq?
get() = aap?.setting()
val deviceInfo: AapDeviceInfo?
get() = aap?.deviceInfo ?: cached?.deviceInfo
@@ -176,6 +176,11 @@ internal class AapSettingsCoordinator(
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
}
// No optimistic update: an optimistic update would render the UI as though an
// unacknowledged write had succeeded, which would actively falsify the on-device
// evaluation this command exists to support.
is AapCommand.SetCustomEq -> return null
is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return null
return baseState.copy(
@@ -211,5 +216,9 @@ internal class AapSettingsCoordinator(
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
is AapCommand.SetDeviceName -> null
// Verification compares against a device-reported setting, and we have no evidence the
// device reports this one at all. A predicate here would manufacture spurious
// divergence-detected churn on every write.
is AapCommand.SetCustomEq -> null
}
}
@@ -36,4 +36,16 @@ sealed class AapCommand {
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
data class SetDeviceName(val name: String) : AapCommand()
data class SetCustomEq(
val mode: AapSetting.CustomEq.Mode,
val low: Int,
val mid: Int,
val high: Int,
) : AapCommand() {
init {
require(low in 0..100) { "low band out of range: $low" }
require(mid in 0..100) { "mid band out of range: $mid" }
require(high in 0..100) { "high band out of range: $high" }
}
}
}
@@ -101,8 +101,10 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) {
/**
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) —
* hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods
* Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see
* the **Headphone Accommodations** configuration, an iOS Accessibility feature
* with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+ hearing-aid
* feature reuses. Not exclusively the hearing-aid audiogram. Decoded as the two
* apply-to flags plus 4 × 8 Float32 band gains (see [AapSetting.PmeConfig]); see
* that data class for the layout rationale. "PME Config" is the label the
* Wireshark AAP dissector uses for this opcode.
*/
@@ -125,6 +127,18 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) {
UNKNOWN_0X58(0x0058, "Unknown"),
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
PERSONAL_TRANSLATION(0x0060, "Personal Translation"),
/**
* Custom EQ — Apple's iOS 27 equalizer feature (announced WWDC 2026) for the H2 models
* (AirPods Pro 3, AirPods Pro 2, AirPods 4). Three bands (low / mid / high) plus a
* Recommended / Custom mode selector; see [AapSetting.CustomEq].
*
* The wire format is sourced from librepods commit `7341e41` and is **unverified on real
* hardware** — no capture from any device we own has ever carried this opcode. Both the
* decoder and the encoder are written to fail loudly rather than guess (see
* [DefaultAapDeviceProfile]).
*/
CUSTOM_EQ(0x0063, "Custom EQ"),
;
companion object {
@@ -183,25 +183,58 @@ sealed class AapSetting {
/**
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment):
* the hearing-aid configuration for Apple's iOS 18.1+ hearing-aid feature on
* AirPods Pro 2.
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment).
*
* Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
* audiogram band gains (e.g. L/R × two environment profiles, 8 frequency
* bands). CAPod previously called this "EQ bands".
* This is the **Headphone Accommodations** configuration — an iOS Accessibility
* feature with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+
* hearing-aid feature reuses. It is not exclusively the hearing-aid audiogram.
*
* Callers should treat all-zero [sets] as "no hearing-aid profile configured"
* — stock firmware reports zeros until the user runs Apple's Hearing Test /
* hearing-aid setup.
* [sets] is decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
* band gains (e.g. L/R × two environment profiles, 8 frequency bands). CAPod
* previously called this "EQ bands".
*
* [applyToMedia] and [applyToPhone] mirror the two "Apply To" checkboxes. They
* describe **scope only** — which audio the accommodation is applied to. They say
* nothing about whether a profile exists: both can be false while band data is
* stored. [isAllZero] is likewise a pure band-data predicate; all-zero gains may
* be flat values rather than an absent profile. Stock firmware does report zeros
* before the user runs Apple's Hearing Test / Headphone Accommodations setup.
*/
data class PmeConfig(
val sets: List<List<Float>>,
val applyToMedia: Boolean,
val applyToPhone: Boolean,
) : AapSetting() {
val isAllZero: Boolean
get() = sets.all { set -> set.all { it == 0f } }
}
/**
* Payload of message type 0x0063 — Apple's iOS 27 "Custom EQ" (see [AapMessageType.CUSTOM_EQ]).
*
* [low], [mid] and [high] are the three band gains, `0..100` with `50` as the neutral
* (no-gain) position. [mode] selects between Apple's recommended curve and the user's
* own band values.
*
* The layout comes from librepods commit `7341e41` and has never been seen on hardware
* we own — treat any decoded value as unconfirmed.
*/
data class CustomEq(
val mode: Mode,
val low: Int,
val mid: Int,
val high: Int,
) : AapSetting() {
enum class Mode(val wireValue: Int) {
RECOMMENDED(0x01),
CUSTOM(0x02);
companion object {
fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value }
}
}
}
/** Per-pod placement reported by the device (command 0x06). */
data class EarDetection(
val primaryPod: PodPlacement,
@@ -100,6 +100,7 @@ class DefaultAapDeviceProfile(
is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled))
is AapCommand.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled))
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
is AapCommand.SetCustomEq -> buildCustomEqMessage(command.mode, command.low, command.mid, command.high)
}
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
@@ -159,13 +160,19 @@ class DefaultAapDeviceProfile(
}
// 0x53 is "PME Config" per the Wireshark AAP dissector — Personal Medical
// Equipment (cf. PPE), i.e. the iOS 18.1+ hearing-aid profile on AirPods
// Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band
// gains); stock firmware reports all-zero until the user runs Apple's
// Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a
// different payload — not decoded here.
// Equipment (cf. PPE), i.e. the Headphone Accommodations configuration that
// the iOS 18.1+ hearing-aid feature reuses. Payload bytes 4 and 5 are the two
// "Apply To" scope flags (Media, Phone); the band gains follow at offset 6,
// decoded verbatim as 4 × 8 Float32 (per-ear × per-profile). Stock firmware
// reports all-zero gains until the user runs Apple's Hearing Test. 0x54
// "Set Band Edges" is a neighbouring opcode with a different payload — not
// decoded here.
if (message.commandType == AapMessageType.PME_CONFIG.value) {
if (message.payload.size < 6 + 128) return null
// Plain 0x01 flags, NOT the Apple-bool 0x01/0x02 encoding used by the
// 0x09 control settings — don't route these through decodeAppleBool.
val applyToMedia = (message.payload[4].toInt() and 0xFF) == 0x01
val applyToPhone = (message.payload[5].toInt() and 0xFF) == 0x01
val sets = mutableListOf<List<Float>>()
var offset = 6 // skip header
for (s in 0 until 4) {
@@ -180,7 +187,11 @@ class DefaultAapDeviceProfile(
}
sets.add(bands)
}
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(
sets = sets,
applyToMedia = applyToMedia,
applyToPhone = applyToPhone,
)
}
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
@@ -206,6 +217,33 @@ class DefaultAapDeviceProfile(
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
}
// Custom EQ (0x63, iOS 27 / H2 models). Payload — everything after the 4-byte AAP
// packet-type/service header and the 2-byte opcode — is `05 00 01 <mode> <low> <mid> <high>`:
// bytes 0-1 are a little-endian declared length (5), byte 2 is a marker whose purpose is
// unidentified, byte 3 is the mode, bytes 4-6 are the three band values (0..100).
//
// Every field is validated and ANY mismatch returns null, which routes the frame to the
// engine's existing unknown-message logging (that prints full hex). We have never seen this
// opcode on hardware — the format is librepods `7341e41` hearsay — so on a firmware whose
// 0x63 dialect differs, preserving the unrecognised frame verbatim in the log beats
// mis-parsing it into plausible-looking values. The size check is deliberately an equality:
// `>= 7` would silently swallow trailing unknown bytes, which is exactly the dialect
// variation we want surfaced. Same shape-validation rationale as the 0x4B branch above.
if (message.commandType == AapMessageType.CUSTOM_EQ.value) {
val p = message.payload
if (p.size < 2) return null
val declaredLength = (p[0].toInt() and 0xFF) or ((p[1].toInt() and 0xFF) shl 8)
if (declaredLength != 5) return null
if (p.size != 2 + declaredLength) return null
if ((p[2].toInt() and 0xFF) != 0x01) return null
val mode = AapSetting.CustomEq.Mode.fromWire(p[3].toInt() and 0xFF) ?: return null
val low = p[4].toInt() and 0xFF
val mid = p[5].toInt() and 0xFF
val high = p[6].toInt() and 0xFF
if (low !in 0..100 || mid !in 0..100 || high !in 0..100) return null
return AapSetting.CustomEq::class to AapSetting.CustomEq(mode, low, mid, high)
}
if (message.commandType != AapMessageType.CONTROL.value) return null
if (message.payload.size < 2) return null
@@ -569,6 +607,34 @@ class DefaultAapDeviceProfile(
) + nameBytes
}
private fun buildCustomEqMessage(
mode: AapSetting.CustomEq.Mode,
low: Int,
mid: Int,
high: Int,
): ByteArray {
// Uses the opcode 0x63 format from librepods commit 7341e41. Unlike the settings writes
// above this is NOT a 0x09 control command — it carries its own message shape, so it can't
// go through buildSettingsMessage.
//
// Layout: header `04 00 04 00`, opcode `63 00`, little-endian declared length `05 00`,
// then `01` — a marker byte whose purpose is unidentified, carried verbatim because
// librepods sends it — the mode, and the three band values (0..100, 50 neutral).
//
// On-device acceptance is UNVERIFIED at time of writing: no device we own has ever emitted
// or acknowledged 0x63, and librepods itself never confirmed the format works. Nothing here
// assumes the write lands — there is deliberately no optimistic update and no verification
// predicate (see AapSettingsCoordinator).
return byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x63, 0x00,
0x05, 0x00,
0x01,
mode.wireValue.toByte(),
low.toByte(), mid.toByte(), high.toByte(),
)
}
/**
* 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
@@ -19,6 +19,7 @@ import eu.darken.capod.monitor.core.battery.DrainProfile
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
@@ -307,6 +308,23 @@ class DeviceSettingsViewModelTest : BaseTest() {
coVerify { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) }
}
@Test
fun `setCustomEq sends exactly one SetCustomEq carrying the drafted tuple`() = runVmTest {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90)
coVerify(exactly = 1) {
aapManager.sendCommand(
testAddress,
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90),
)
}
coVerify(exactly = 1) { aapManager.sendCommand(any(), any<AapCommand.SetCustomEq>()) }
}
@Test
fun `setDeviceName when no target address is a no-op`() = runVmTest {
val vm = createViewModel()
@@ -0,0 +1,111 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* AapOutboundController queues every ear-gated command while no pod is worn, and the settings
* coordinator collapses repeated ones to the latest tuple, so a tap made with the pods out would
* emit its packet at some unrelated later moment. That destroys the logcat observation window this
* debug card exists to produce, hence Apply has to be unavailable while a write would be queued.
*
* The controller only gates when the AAP EarDetection setting is present, so with no such report
* the write goes out immediately and Apply must stay available.
*/
class CustomEqDebugCardTest : BaseComposeRobolectricTest() {
private val applyButton = hasText("Apply") and hasClickAction()
private var applies = 0
private fun device(earDetection: AapSetting.EarDetection?) = PodDevice(
profileId = "test",
ble = null,
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = earDetection?.let { mapOf(AapSetting.EarDetection::class to it) } ?: emptyMap(),
),
)
private fun setContent(earDetection: AapSetting.EarDetection?) {
composeRule.setContent {
PreviewWrapper {
// Scrollable, because the card is taller than the test window and the Apply row
// sits at its bottom. Without a scroll the taps would land off-screen and every
// "no command was sent" assertion would pass for the wrong reason.
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
CustomEqDebugCard(
device = device(earDetection),
enabled = true,
onApply = { _, _, _, _ -> applies++ },
)
}
}
}
}
private fun clickApply() {
composeRule.onNode(applyButton).performScrollTo().performClick()
}
@Test
fun `apply is available while a pod is in ear`() {
setContent(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
),
)
composeRule.onNode(applyButton).assertIsEnabled()
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist()
clickApply()
composeRule.runOnIdle { applies shouldBe 1 }
}
@Test
fun `apply is unavailable with both pods out, with a notice next to the button`() {
setContent(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
composeRule.onNode(applyButton).assertIsNotEnabled()
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertExists()
clickApply()
composeRule.runOnIdle { applies shouldBe 0 }
}
@Test
fun `apply is available while no ear detection is reported at all`() {
// No 0x06 report yet, so AapOutboundController's ear gate does not engage and the write
// is sent right away. Blocking here would be both pointless and factually wrong.
setContent(null)
composeRule.onNode(applyButton).assertIsEnabled()
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist()
clickApply()
composeRule.runOnIdle { applies shouldBe 1 }
}
}
@@ -186,6 +186,198 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
}
}
// ── PME Config / Headphone Accommodations (0x53) ────────
@Nested
inner class PmeConfigTests {
/**
* Build a 0x53 frame: 4 unknown header bytes, the two apply-to flags at
* offsets 4 and 5, then 4 × 8 little-endian Float32 band gains.
*/
private fun pmeMessage(
applyToMediaByte: Int,
applyToPhoneByte: Int,
sets: List<List<Float>>,
): AapMessage {
val payload = mutableListOf<Byte>(0x00, 0x00, 0x00, 0x00)
payload.add(applyToMediaByte.toByte())
payload.add(applyToPhoneByte.toByte())
for (set in sets) {
for (band in set) {
val bits = band.toRawBits()
for (shift in 0..3) payload.add(((bits shr (shift * 8)) and 0xFF).toByte())
}
}
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00)
return AapMessage.parse(header + payload.toByteArray())!!
}
private val zeroSets = List(4) { List(8) { 0f } }
@Test
fun `decode both apply-to flags set`() {
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x01, zeroSets))
config.applyToMedia shouldBe true
config.applyToPhone shouldBe true
}
@Test
fun `decode media only`() {
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x00, zeroSets))
config.applyToMedia shouldBe true
config.applyToPhone shouldBe false
}
@Test
fun `decode phone only`() {
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x00, 0x01, zeroSets))
config.applyToMedia shouldBe false
config.applyToPhone shouldBe true
}
@Test
fun `decode neither flag set`() {
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x00, 0x00, zeroSets))
config.applyToMedia shouldBe false
config.applyToPhone shouldBe false
}
@Test
fun `flags are plain 0x01 flags, not Apple-bool`() {
// Apple-bool would read 0x02 as "false" too, but so does a plain flag check —
// what matters is that anything other than 0x01 is false, including 0x02.
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x02, 0x02, zeroSets))
config.applyToMedia shouldBe false
config.applyToPhone shouldBe false
}
@Test
fun `band data still decodes from offset 6`() {
val sets = List(4) { setIndex -> List(8) { band -> (setIndex * 8 + band).toFloat() + 0.5f } }
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x00, sets))
config.sets shouldBe sets
config.isAllZero shouldBe false
}
@Test
fun `all-zero band data reports isAllZero regardless of flags`() {
decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x01, zeroSets)).isAllZero shouldBe true
}
@Test
fun `decode rejects truncated payload`() {
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00)
val short = AapMessage.parse(header + ByteArray(6 + 127))!!
profile.decodeSetting(short).shouldBeNull()
}
}
// ── Custom EQ (0x63) ────────────────────────────────────
@Nested
inner class CustomEqTests {
@Test
fun `decode custom mode frame`() {
val eq = decodeSetting<AapSetting.CustomEq>(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64"))
eq.mode shouldBe AapSetting.CustomEq.Mode.CUSTOM
eq.low shouldBe 10
eq.mid shouldBe 50
eq.high shouldBe 100
}
@Test
fun `decode recommended mode frame`() {
val eq = decodeSetting<AapSetting.CustomEq>(aapMessage("04 00 04 00 63 00 05 00 01 01 32 32 32"))
eq.mode shouldBe AapSetting.CustomEq.Mode.RECOMMENDED
eq.low shouldBe 50
eq.mid shouldBe 50
eq.high shouldBe 50
}
@Test
fun `decode rejects wrong declared length`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 04 00 01 02 0A 32 64")).shouldBeNull()
}
@Test
fun `decode rejects unknown marker byte`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 02 02 0A 32 64")).shouldBeNull()
}
@Test
fun `decode rejects unknown mode`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 03 0A 32 64")).shouldBeNull()
}
@Test
fun `decode rejects band above 100`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 65 64")).shouldBeNull()
}
@Test
fun `decode rejects truncated payload`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32")).shouldBeNull()
}
@Test
fun `decode rejects trailing extra bytes`() {
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64 00")).shouldBeNull()
}
@Test
fun `encode locks in the full wire format`() {
val bytes = profile.encodeCommand(
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 50, high = 100),
)
val expected = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x63, 0x00,
0x05, 0x00,
0x01,
0x02,
0x0A, 0x32, 0x64,
)
bytes shouldBe expected
}
@Test
fun `encode recommended mode`() {
val bytes = profile.encodeCommand(
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.RECOMMENDED, low = 0, mid = 0, high = 0),
)
bytes shouldBe byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x63, 0x00,
0x05, 0x00,
0x01,
0x01,
0x00, 0x00, 0x00,
)
}
@Test
fun `command rejects band below range`() {
assertThrows<IllegalArgumentException> {
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = -1, mid = 50, high = 50)
}
}
@Test
fun `command rejects band above range`() {
assertThrows<IllegalArgumentException> {
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 50, mid = 101, high = 50)
}
}
@Test
fun `command accepts range boundaries`() {
val command = AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 0, mid = 100, high = 0)
command.low shouldBe 0
command.mid shouldBe 100
}
}
// ── Stem Press Events (0x19) ────────────────────────────
@Nested
@@ -259,6 +259,26 @@ class AapSettingsCoordinatorTest : BaseTest() {
result.deviceInfo!!.name shouldBe "New Name"
}
@Test
fun `SetCustomEq produces no optimistic update`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.CustomEq::class to AapSetting.CustomEq(
mode = AapSetting.CustomEq.Mode.RECOMMENDED,
low = 50,
mid = 50,
high = 50,
),
)
val result = coord.optimisticUpdate(
state,
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30),
)
result.shouldBeNull()
}
@Test
fun `does not mutate input state`() {
val coord = createCoordinator()
@@ -281,6 +301,14 @@ class AapSettingsCoordinatorTest : BaseTest() {
coord.verificationFor(AapCommand.SetDeviceName("test")).shouldBeNull()
}
@Test
fun `verificationFor returns null for SetCustomEq`() {
val coord = createCoordinator()
coord.verificationFor(
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30)
).shouldBeNull()
}
@Test
fun `verificationFor returns correct check for ANC mode`() {
val coord = createCoordinator()