diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 2bd1f2fd..4659b4c1 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -15,6 +15,7 @@ import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot 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 java.time.Duration import java.time.Instant /** @@ -45,9 +46,26 @@ data class PodDevice( // Signal / timing val seenLastAt: Instant? get() = ble?.seenLastAt val seenFirstAt: Instant? get() = ble?.seenFirstAt - val signalQuality: Float get() = ble?.signalQuality ?: 0f + val signalQuality: Float + get() { + val bleQuality = ble?.signalQuality ?: 0f + return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f) + } val rssi: Int get() = ble?.rssi ?: 0 + internal fun computeAapBoost(now: Instant): Float { + val state = aap ?: return 0f + if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f + val lastMessage = state.lastMessageAt ?: return 0.05f + val ageSeconds = Duration.between(lastMessage, now).seconds + return when { + ageSeconds < 0 -> 0.05f // future timestamp (defensive) + ageSeconds < 10 -> 0.15f // fresh + ageSeconds < 30 -> 0.10f // warm + else -> 0.05f // stale + } + } + // Battery — AAP preferred, BLE fallback val batteryLeft: Float? get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index b0f93149..261642a6 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -13,6 +13,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult +import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -179,7 +180,7 @@ internal class AapConnection( // Try battery profile.decodeBattery(message)?.let { batteries -> - _state.value = _state.value.copy(batteries = batteries) + _state.value = _state.value.copy(batteries = batteries, lastMessageAt = Instant.now()) log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } return } @@ -187,20 +188,21 @@ internal class AapConnection( // Try private key response profile.decodePrivateKeyResponse(message)?.let { keys -> log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" } + _state.value = _state.value.copy(lastMessageAt = Instant.now()) _keysReceived.tryEmit(keys) return } // Try device info profile.decodeDeviceInfo(message)?.let { info -> - _state.value = _state.value.copy(deviceInfo = info) + _state.value = _state.value.copy(deviceInfo = info, lastMessageAt = Instant.now()) log(TAG) { "Device info: ${info.name} (${info.modelNumber})" } return } // Try setting update (merge into existing state) profile.decodeSetting(message)?.let { (key, value) -> - _state.value = _state.value.withSetting(key, value) + _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now()) log(TAG) { "Setting: ${key.simpleName} = $value" } return } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index 90776921..c9316a4e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.apple.aap import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import java.time.Instant import kotlin.reflect.KClass /** @@ -12,6 +13,7 @@ data class AapPodState( val deviceInfo: AapDeviceInfo? = null, val settings: Map, AapSetting> = emptyMap(), val batteries: Map = emptyMap(), + val lastMessageAt: Instant? = null, ) { inline fun setting(): T? = settings[T::class] as? T 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 5be599af..ee0ba152 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 @@ -245,4 +245,114 @@ class PodDeviceTest : BaseTest() { device.address shouldBe bondedAddress device.bleAddress shouldBe bleRpa } + + // --- AAP signal quality boost tests --- + + private fun deviceWithBleQuality(bleQuality: Float, aap: AapPodState? = null): PodDevice { + val ble = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { signalQuality } returns bleQuality + } + return PodDevice(ble = ble, aap = aap) + } + + @Test + fun `AAP boost - READY with fresh message applies 0_15`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(5), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.15f + } + + @Test + fun `AAP boost - READY with warm message applies 0_10`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(20), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.10f + } + + @Test + fun `AAP boost - READY with stale message applies 0_05`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(60), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.05f + } + + @Test + fun `AAP boost - boundary at exactly 10s is warm tier`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(10), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.10f + } + + @Test + fun `AAP boost - boundary at exactly 30s is stale tier`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(30), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.05f + } + + @Test + fun `AAP boost - signalQuality clamps to 1_0`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(5), + ) + val device = deviceWithBleQuality(0.95f, aap) + device.signalQuality shouldBe 1.0f + } + + @Test + fun `AAP boost - CONNECTING state applies 0_05`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState(connectionState = AapPodState.ConnectionState.CONNECTING) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.05f + } + + @Test + fun `AAP boost - future timestamp treated as stale`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.plusSeconds(60), + ) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.05f + } + + @Test + fun `AAP boost - READY with null lastMessageAt returns 0_05`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = deviceWithBleQuality(0.50f, aap) + device.computeAapBoost(now) shouldBe 0.05f + } + + @Test + fun `AAP boost - no AAP returns zero`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val device = deviceWithBleQuality(0.50f, aap = null) + device.computeAapBoost(now) shouldBe 0f + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt index a24f787d..d2a5dc84 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt @@ -76,6 +76,7 @@ class AapPodStateTest : BaseTest() { state.deviceInfo.shouldBeNull() state.settings shouldBe emptyMap() state.batteries shouldBe emptyMap() + state.lastMessageAt.shouldBeNull() } @Test