mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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,
|
||||
|
||||
+72
-6
@@ -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
|
||||
|
||||
+18
@@ -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()
|
||||
|
||||
+111
@@ -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 }
|
||||
}
|
||||
}
|
||||
+192
@@ -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
|
||||
|
||||
+28
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user