mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4d87c06a |
@@ -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.BatteryCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
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.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.DeviceInfoCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||||
@@ -190,6 +191,7 @@ fun DeviceSettingsScreenHost(
|
|||||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||||
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
||||||
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
||||||
|
onCustomEqApply = { mode, low, mid, high -> vm.setCustomEq(mode, low, mid, high) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +237,7 @@ fun DeviceSettingsScreen(
|
|||||||
onOpenAapTracker: () -> Unit = {},
|
onOpenAapTracker: () -> Unit = {},
|
||||||
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||||
onResetBatteryEstimate: () -> Unit = {},
|
onResetBatteryEstimate: () -> Unit = {},
|
||||||
|
onCustomEqApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> },
|
||||||
) {
|
) {
|
||||||
val device = state.device
|
val device = state.device
|
||||||
val features = device?.model?.features
|
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
|
// 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))
|
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 {
|
fun setDeviceName(name: String) = launch {
|
||||||
val address = currentAddress() ?: return@launch
|
val address = currentAddress() ?: return@launch
|
||||||
try {
|
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?
|
val pmeConfig: AapSetting.PmeConfig?
|
||||||
get() = aap?.setting()
|
get() = aap?.setting()
|
||||||
|
|
||||||
|
val customEq: AapSetting.CustomEq?
|
||||||
|
get() = aap?.setting()
|
||||||
|
|
||||||
val deviceInfo: AapDeviceInfo?
|
val deviceInfo: AapDeviceInfo?
|
||||||
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ internal class AapSettingsCoordinator(
|
|||||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
|
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 -> {
|
is AapCommand.SetDeviceName -> {
|
||||||
val currentInfo = baseState.deviceInfo ?: return null
|
val currentInfo = baseState.deviceInfo ?: return null
|
||||||
return baseState.copy(
|
return baseState.copy(
|
||||||
@@ -211,5 +216,9 @@ internal class AapSettingsCoordinator(
|
|||||||
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
||||||
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
|
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
|
||||||
is AapCommand.SetDeviceName -> null
|
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 SetSleepDetection(val enabled: Boolean) : AapCommand()
|
||||||
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
|
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
|
||||||
data class SetDeviceName(val name: String) : 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) —
|
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) —
|
||||||
* hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods
|
* the **Headphone Accommodations** configuration, an iOS Accessibility feature
|
||||||
* Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see
|
* 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
|
* that data class for the layout rationale. "PME Config" is the label the
|
||||||
* Wireshark AAP dissector uses for this opcode.
|
* Wireshark AAP dissector uses for this opcode.
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +127,18 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) {
|
|||||||
UNKNOWN_0X58(0x0058, "Unknown"),
|
UNKNOWN_0X58(0x0058, "Unknown"),
|
||||||
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
|
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
|
||||||
PERSONAL_TRANSLATION(0x0060, "Personal Translation"),
|
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 {
|
companion object {
|
||||||
|
|||||||
@@ -183,25 +183,58 @@ sealed class AapSetting {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
|
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
|
||||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment):
|
* 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.
|
|
||||||
*
|
*
|
||||||
* Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
|
* This is the **Headphone Accommodations** configuration — an iOS Accessibility
|
||||||
* audiogram band gains (e.g. L/R × two environment profiles, 8 frequency
|
* feature with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+
|
||||||
* bands). CAPod previously called this "EQ bands".
|
* hearing-aid feature reuses. It is not exclusively the hearing-aid audiogram.
|
||||||
*
|
*
|
||||||
* Callers should treat all-zero [sets] as "no hearing-aid profile configured"
|
* [sets] is decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
|
||||||
* — stock firmware reports zeros until the user runs Apple's Hearing Test /
|
* band gains (e.g. L/R × two environment profiles, 8 frequency bands). CAPod
|
||||||
* hearing-aid setup.
|
* 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(
|
data class PmeConfig(
|
||||||
val sets: List<List<Float>>,
|
val sets: List<List<Float>>,
|
||||||
|
val applyToMedia: Boolean,
|
||||||
|
val applyToPhone: Boolean,
|
||||||
) : AapSetting() {
|
) : AapSetting() {
|
||||||
val isAllZero: Boolean
|
val isAllZero: Boolean
|
||||||
get() = sets.all { set -> set.all { it == 0f } }
|
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). */
|
/** Per-pod placement reported by the device (command 0x06). */
|
||||||
data class EarDetection(
|
data class EarDetection(
|
||||||
val primaryPod: PodPlacement,
|
val primaryPod: PodPlacement,
|
||||||
|
|||||||
+72
-6
@@ -100,6 +100,7 @@ class DefaultAapDeviceProfile(
|
|||||||
is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled))
|
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.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled))
|
||||||
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
|
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>? {
|
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
|
// 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
|
// Equipment (cf. PPE), i.e. the Headphone Accommodations configuration that
|
||||||
// Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band
|
// the iOS 18.1+ hearing-aid feature reuses. Payload bytes 4 and 5 are the two
|
||||||
// gains); stock firmware reports all-zero until the user runs Apple's
|
// "Apply To" scope flags (Media, Phone); the band gains follow at offset 6,
|
||||||
// Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a
|
// decoded verbatim as 4 × 8 Float32 (per-ear × per-profile). Stock firmware
|
||||||
// different payload — not decoded here.
|
// 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.commandType == AapMessageType.PME_CONFIG.value) {
|
||||||
if (message.payload.size < 6 + 128) return null
|
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>>()
|
val sets = mutableListOf<List<Float>>()
|
||||||
var offset = 6 // skip header
|
var offset = 6 // skip header
|
||||||
for (s in 0 until 4) {
|
for (s in 0 until 4) {
|
||||||
@@ -180,7 +187,11 @@ class DefaultAapDeviceProfile(
|
|||||||
}
|
}
|
||||||
sets.add(bands)
|
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
|
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
|
||||||
@@ -206,6 +217,33 @@ class DefaultAapDeviceProfile(
|
|||||||
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
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.commandType != AapMessageType.CONTROL.value) return null
|
||||||
if (message.payload.size < 2) return null
|
if (message.payload.size < 2) return null
|
||||||
|
|
||||||
@@ -569,6 +607,34 @@ class DefaultAapDeviceProfile(
|
|||||||
) + nameBytes
|
) + 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.
|
* 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
|
* 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.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
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.AapCommand
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
@@ -307,6 +308,23 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
|||||||
coVerify { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) }
|
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
|
@Test
|
||||||
fun `setDeviceName when no target address is a no-op`() = runVmTest {
|
fun `setDeviceName when no target address is a no-op`() = runVmTest {
|
||||||
val vm = createViewModel()
|
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) ────────────────────────────
|
// ── Stem Press Events (0x19) ────────────────────────────
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|||||||
+28
@@ -259,6 +259,26 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
result.deviceInfo!!.name shouldBe "New Name"
|
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
|
@Test
|
||||||
fun `does not mutate input state`() {
|
fun `does not mutate input state`() {
|
||||||
val coord = createCoordinator()
|
val coord = createCoordinator()
|
||||||
@@ -281,6 +301,14 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
coord.verificationFor(AapCommand.SetDeviceName("test")).shouldBeNull()
|
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
|
@Test
|
||||||
fun `verificationFor returns correct check for ANC mode`() {
|
fun `verificationFor returns correct check for ANC mode`() {
|
||||||
val coord = createCoordinator()
|
val coord = createCoordinator()
|
||||||
|
|||||||
Reference in New Issue
Block a user