fix: Correct wear detection on AirPods Max 2

Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
This commit is contained in:
darken
2026-05-01 21:50:51 +02:00
committed by Matthias Urhahn
parent 0ffa87deea
commit 5a8c4b94ef
3 changed files with 43 additions and 1 deletions
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -200,6 +201,23 @@ class PodDeviceTest : BaseTest() {
device.isEitherPodInEar shouldBe true
}
@Test
fun `BLE isBeingWorn delegates to HasEarDetection on single-pod devices`() {
val mock = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns true
}
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isBeingWorn shouldBe true
val mockNotWorn = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
}
val deviceNotWorn = PodDevice(profileId = null, ble = mockNotWorn, aap = null)
deviceNotWorn.isBeingWorn shouldBe false
}
@Test
fun `AAP ear detection preferred over BLE`() {
val mock = mockk<DualApplePods>(relaxed = true) {
@@ -24,6 +24,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -35,6 +36,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x20.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -42,6 +44,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x21.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -49,6 +52,17 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x23.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
// Synthetic: one earcup off, the other on (issue #548 sees 0x29 in this state on
// both macOS and Android; we don't claim left vs right side semantics here).
create<AirPodsMax2>("07 19 01 2D 20 29 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") {
pubStatus shouldBe 0x29.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
}