From 5a8c4b94efe8f6fe01ee461b20d27c76bd50dde4 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 1 May 2026 21:49:03 +0200 Subject: [PATCH] fix: Correct wear detection on AirPods Max 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../apple/ble/devices/airpods/AirPodsMax2.kt | 12 +++++++++++- .../darken/capod/monitor/core/PodDeviceTest.kt | 18 ++++++++++++++++++ .../ble/devices/airpods/AirPodsMax2Test.kt | 14 ++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt index d28f063d..e3c9bcfb 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2.kt @@ -44,8 +44,18 @@ data class AirPodsMax2( return pubFlags.isBitSet(0) } + // Aggregate "any wear sensor active" — NOT "both earcups worn". + // Observed on A3454: bit 5 of pubStatus is always set while advertising and + // no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of + // pubStatus reflect the two earcup sensors (same byte positions used by + // DualApplePods, but without the primary/flip semantics). + // + // We OR them because some Android pairings only see one of the two bits + // reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only — + // while macOS sees 0x2B with both bits set). AND-ing would falsely report + // "not worn" during normal use on those phones. override val isBeingWorn: Boolean - get() = pubStatus.isBitSet(5) + get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3) class Factory @Inject constructor( private val repo: PodHistoryRepo, diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 0d426bce..27b2fb18 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -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(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(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(relaxed = true) { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt index 6257a91e..3c732c6b 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/airpods/AirPodsMax2Test.kt @@ -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("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 } }