mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
+14
@@ -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.
|
||||
|
||||
+25
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user