mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat: Integrate AAP connection freshness into signal quality indicator
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<KClass<out AapSetting>, AapSetting> = emptyMap(),
|
||||
val batteries: Map<BatteryType, Battery> = emptyMap(),
|
||||
val lastMessageAt: Instant? = null,
|
||||
) {
|
||||
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
|
||||
|
||||
|
||||
@@ -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<DualApplePods>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ class AapPodStateTest : BaseTest() {
|
||||
state.deviceInfo.shouldBeNull()
|
||||
state.settings shouldBe emptyMap()
|
||||
state.batteries shouldBe emptyMap()
|
||||
state.lastMessageAt.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user