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.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent b1aee5ee83
commit 04c23ecf63
3 changed files with 114 additions and 9 deletions
@@ -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)
@@ -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<DualApplePods>()
.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<DualApplePods>()