diff --git a/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt index 6b8adfba..88f16e4d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt @@ -19,6 +19,7 @@ import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.PodFactory +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.currentProfiles @@ -189,14 +190,40 @@ class BlePodMonitor @Inject constructor( pods.putAll(deviceCache) - newPods.map { it.device }.forEach { - deviceCache[it.identifier] = it - pods[it.identifier] = it + newPods.map { it.device }.forEach { newPod -> + val existing = pods[newPod.identifier] + val preferred = if (existing != null) { + preferCaseContextPod(existing, newPod) + } else { + newPod + } + deviceCache[preferred.identifier] = preferred + pods[preferred.identifier] = preferred } return pods } + /** + * When two scan results in the same batch map to the same device identity, + * prefer the one broadcasting from inside the case (has case context bits set). + * It carries authoritative case state and battery data. + */ + private fun preferCaseContextPod( + existing: BlePodSnapshot, + incoming: BlePodSnapshot, + ): BlePodSnapshot { + val existingDual = existing as? DualApplePods + val incomingDual = incoming as? DualApplePods + if (existingDual == null || incomingDual == null) return incoming + + return when { + existingDual.hasCaseContext && !incomingDual.hasCaseContext -> existing + !existingDual.hasCaseContext && incomingDual.hasCaseContext -> incoming + else -> incoming + } + } + companion object { private val TAG = logTag("Monitor", "PodMonitor") private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/ApplePodsFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/ApplePodsFactory.kt index 98840a78..8a675f32 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/ApplePodsFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/devices/ApplePodsFactory.kt @@ -22,13 +22,29 @@ interface ApplePodsFactory { fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? { - val definitive = setOf( - DualApplePods.LidState.OPEN, - DualApplePods.LidState.CLOSED, - DualApplePods.LidState.NOT_IN_CASE, - ) - if (definitive.contains(basic.caseLidState)) return basic.caseLidState + // A pod broadcasting from inside the case has authoritative case state + if (basic.hasCaseContext && basic.caseLidState in setOf( + DualApplePods.LidState.OPEN, + DualApplePods.LidState.CLOSED, + ) + ) { + return basic.caseLidState + } + // Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN. + // Check recent history for a sibling broadcast that has case context. + val fromCaseContext = history + .takeLast(4) + .filterIsInstance() + .lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN } + ?.caseLidState + + if (fromCaseContext != null) return fromCaseContext + + // No case-context broadcast in recent history — current value is best we have + if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState + + // Last resort: any non-UNKNOWN from history return history .takeLast(2) .filterIsInstance() diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePodsTest.kt index 0fd7ccc2..a35abb92 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/DualApplePodsTest.kt @@ -1,8 +1,10 @@ package eu.darken.capod.pods.core.apple.ble.devices import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.ble.devices.airpods.HasStateDetectionAirPods +import eu.darken.capod.pods.core.apple.ble.history.KnownDevice import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest @@ -214,6 +216,8 @@ class DualApplePodsTest : BaseBlePodsTest() { } } + private fun directAirPodsPro(status: Int) = directAirPodsPro(status, 0x31) + private fun directAirPodsPro( status: Int, rawCaseLidState: Int, @@ -253,6 +257,64 @@ class DualApplePodsTest : BaseBlePodsTest() { directAirPodsPro(status = 0x20, rawCaseLidState = 0x5A).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE } + private fun knownDeviceOf(vararg pods: AirPodsPro): KnownDevice { + val id = pods.first().identifier + return KnownDevice( + id = id, + seenFirstAt = pods.first().seenFirstAt, + seenCounter = pods.size, + history = pods.toList(), + lastCaseBattery = null, + ) + } + + private val testFactory = object : ApplePodsFactory { + override fun isResponsible(message: eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage) = false + override suspend fun create( + scanResult: BleScanResult, + payload: ProximityPayload, + meta: ApplePods.AppleMeta, + ): ApplePods = throw UnsupportedOperationException() + } + + @Test + fun `getLatestCaseLidState - desk pod defers to case-context history`() { + val sharedId = BlePodSnapshot.Id() + // Pod in case: status=0x55 has bits 2,4,6 set → hasCaseContext=true, lid=OPEN + val casePod = directAirPodsPro(status = 0x55, rawCaseLidState = 0x31).copy(identifier = sharedId) + // Pod on desk: status=0x20 has no case bits → hasCaseContext=false → NOT_IN_CASE + val deskPod = directAirPodsPro(status = 0x20, rawCaseLidState = 0x31).copy(identifier = sharedId) + + val known = knownDeviceOf(casePod, deskPod) + val result = with(testFactory) { known.getLatestCaseLidState(deskPod) } + + result shouldBe DualApplePods.LidState.OPEN + } + + @Test + fun `getLatestCaseLidState - case pod is authoritative`() { + val sharedId = BlePodSnapshot.Id() + val deskPod = directAirPodsPro(status = 0x20, rawCaseLidState = 0x31).copy(identifier = sharedId) + val casePod = directAirPodsPro(status = 0x55, rawCaseLidState = 0x39).copy(identifier = sharedId) + + val known = knownDeviceOf(deskPod, casePod) + val result = with(testFactory) { known.getLatestCaseLidState(casePod) } + + result shouldBe DualApplePods.LidState.CLOSED + } + + @Test + fun `getLatestCaseLidState - no case context in history returns NOT_IN_CASE`() { + val sharedId = BlePodSnapshot.Id() + val deskPod1 = directAirPodsPro(status = 0x20, rawCaseLidState = 0x31).copy(identifier = sharedId) + val deskPod2 = directAirPodsPro(status = 0x20, rawCaseLidState = 0x31).copy(identifier = sharedId) + + val known = knownDeviceOf(deskPod1, deskPod2) + val result = with(testFactory) { known.getLatestCaseLidState(deskPod2) } + + result shouldBe DualApplePods.LidState.NOT_IN_CASE + } + @Test fun `test AirPodDevice - connection state`() = runTest { // Disconnected