feat(aap): Surface Optimized Charge indicator and Pro 3 toggle

Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B).

- Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false

- Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case

- Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING

- New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection)

- Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure
This commit is contained in:
darken
2026-04-24 17:27:48 +02:00
committed by Matthias Urhahn
parent 89e8b83be4
commit 59b3106c3b
21 changed files with 327 additions and 15 deletions
@@ -117,6 +117,20 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x35, 0x00)).shouldBeNull() }
}
// ── Dynamic End of Charge / "Optimized Charge Limit" (0x3B) ─
// Apple-bool wire semantics matching every other boolean setting we've decoded. Real
// capture on a Pro 3 after handshake showed rawValue 0x01 (enabled).
@Nested
inner class DynamicEndOfChargeTests {
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(true))[7] shouldBe 0x01.toByte() }
@Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(false))[7] shouldBe 0x02.toByte() }
@Test fun `encode carries the right setting id`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(true))[6] shouldBe 0x3B.toByte() }
@Test fun `decode enabled`() { decodeSetting<AapSetting.DynamicEndOfCharge>(settingsMessage(0x3B, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.DynamicEndOfCharge>(settingsMessage(0x3B, 0x02)).enabled shouldBe false }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x3B, 0x00)).shouldBeNull() }
}
// ── In-Case Tone (0x31) ─────────────────────────────────
// Decode path is kept internally even though the setting is no longer exposed in the UI.
// See the IN_CASE_TONE branch in DefaultAapDeviceProfile.decodeSetting for rationale.
@@ -171,6 +171,28 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
@Test fun `NC one airpod OFF`() {
decodeSetting<AapSetting.NcWithOneAirPod>("04 00 04 00 09 00 1B 02 00 00 00").enabled shouldBe false
}
// DynamicEndOfCharge — Apple's "Optimized Charge Limit", setting 0x3B. Real capture
// from a Pro 3 after handshake showed rawValue=0x01 (enabled). Apple-bool semantics
// are our current assumption; decodeAppleBool rejects values that aren't 0x01/0x02.
@Test fun `dynamic end of charge ON - observed on connect`() {
decodeSetting<AapSetting.DynamicEndOfCharge>("04 00 04 00 09 00 3B 01 00 00 00").enabled shouldBe true
}
@Test fun `dynamic end of charge OFF`() {
decodeSetting<AapSetting.DynamicEndOfCharge>("04 00 04 00 09 00 3B 02 00 00 00").enabled shouldBe false
}
@Test fun `dynamic end of charge - unknown raw value is not decoded as bool`() {
// 0x00 isn't Apple-bool — decoder must fall through (return null) instead of
// silently coercing to false. This keeps us honest about the wire format and
// lets the unhandled-setting path log the raw bytes for follow-up.
profile.decodeSetting(
eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.Message.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x3B, 0x00, 0x00, 0x00, 0x00)
)!!
).shouldBeNull()
}
}
// ── ANC Mode Switching (verified audible) ────────────────
@@ -252,7 +274,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
@Test
fun `unconfirmed settings IDs decode as UnknownSetting`() {
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
// 0x3B (DYNAMIC_END_OF_CHARGE) was previously on this list but has since been
// promoted to the DynamicEndOfCharge decoder — see DynamicEndOfCharge tests above.
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38)
for (id in unconfirmedIds) {
val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x01))
setting.settingId shouldBe id
@@ -280,6 +280,27 @@ class AapSessionEngineTest : BaseTest() {
engineWithEar.state.value.pendingSettingsCount shouldBe 1
}
@Test
fun `send bypasses ear gate for SetDynamicEndOfCharge`() = runTest(UnconfinedTestDispatcher()) {
// Unlike most settings, the charge-cap toggle is used while pods sit in the
// closed case. Queueing it until worn would make the toggle look broken.
val profile = mockProfile {
every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
))
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
engine.processMessage(dummyMessage()) // Set ear detection to IN_CASE
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetDynamicEndOfCharge(false)) { sentCommands.add(it) }
sentCommands shouldBe listOf(AapCommand.SetDynamicEndOfCharge(false))
engine.state.value.pendingSettingsCount shouldBe 0
}
@Test
fun `send immediate when pod in ear`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile {