From 04c23ecf63d305dd4150a7f77aa59fe2aef2dadd Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 13 Apr 2026 18:40:28 +0200 Subject: [PATCH] fix(ble): Prefer case-context pod when merging split BLE broadcasts When both pods broadcast independently (one in case, one on desk), the pod inside the case carries authoritative case state via hasCaseContext bits. Previously, whichever address was processed last would overwrite the other, causing case state to flip-flop between OPEN and NOT_IN_CASE every scan cycle. Two-layer fix: BlePodMonitor.processWithCache() now prefers the pod with case context when two scan results map to the same identity in one batch. ApplePodsFactory.getLatestCaseLidState() no longer treats NOT_IN_CASE as authoritative when recent history contains a broadcast with case context. --- .../capod/monitor/core/ble/BlePodMonitor.kt | 33 +++++++++- .../apple/ble/devices/ApplePodsFactory.kt | 28 +++++++-- .../apple/ble/devices/DualApplePodsTest.kt | 62 +++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) 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