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.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