mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
fix(ui): Fix signal bars and restore individual connection icons
This commit is contained in:
@@ -668,4 +668,129 @@ class PodDeviceTest : BaseTest() {
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - profile IRK_AND_ENCRYPTED wins while AAP is live with no BLE`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = aap,
|
||||
profileKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - profile IRK_ONLY shown while AAP is live with no BLE`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = aap,
|
||||
profileKeyState = BleKeyState.IRK_ONLY,
|
||||
)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_ONLY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - profile keys ignored when device is not live`() {
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = null,
|
||||
profileKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
)
|
||||
device.bleKeyState shouldBe BleKeyState.NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - profile keys override unmatched live BLE`() {
|
||||
val ble = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { meta } returns ApplePods.AppleMeta(isIRKMatch = false)
|
||||
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
|
||||
}
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = ble,
|
||||
aap = null,
|
||||
profileKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
|
||||
}
|
||||
|
||||
// --- rssiQuality tests ---
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - uses BLE value when BLE present`() {
|
||||
val ble = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { rssiQuality } returns 0.42f
|
||||
}
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.rssiQuality shouldBe 0.42f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - zero when no BLE and no AAP`() {
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.rssiQuality shouldBe 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - zero when AAP CONNECTING`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.CONNECTING)
|
||||
val device = PodDevice(profileId = "p1", ble = null, aap = aap)
|
||||
device.rssiQuality shouldBe 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - zero when AAP HANDSHAKING`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING)
|
||||
val device = PodDevice(profileId = "p1", ble = null, aap = aap)
|
||||
device.rssiQuality shouldBe 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - full when AAP READY regardless of lastMessageAt null`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY, lastMessageAt = null)
|
||||
val device = PodDevice(profileId = "p1", ble = null, aap = aap)
|
||||
device.rssiQuality shouldBe 1.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - full when AAP READY with fresh message`() {
|
||||
val now = Instant.parse("2026-01-01T12:00:00Z")
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
lastMessageAt = now.minusSeconds(5),
|
||||
)
|
||||
val device = PodDevice(profileId = "p1", ble = null, aap = aap)
|
||||
device.rssiQuality shouldBe 1.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - full when AAP READY with very old message (quiet channel)`() {
|
||||
// AirPods don't send periodic AAP messages — a user listening to music with no UI
|
||||
// interaction for 10 minutes is still a healthy connection, not a degraded one.
|
||||
val now = Instant.parse("2026-01-01T12:00:00Z")
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
lastMessageAt = now.minusSeconds(600),
|
||||
)
|
||||
val device = PodDevice(profileId = "p1", ble = null, aap = aap)
|
||||
device.rssiQuality shouldBe 1.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality - BLE value wins over AAP READY`() {
|
||||
val ble = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { rssiQuality } returns 0.3f
|
||||
}
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(profileId = "p1", ble = ble, aap = aap)
|
||||
device.rssiQuality shouldBe 0.3f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import io.kotest.matchers.floats.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class BlePodSnapshotTest : BaseTest() {
|
||||
|
||||
private fun fakeSnapshot(
|
||||
rssi: Int,
|
||||
reliability: Float = 0f,
|
||||
seenFirstAt: Instant = Instant.now(),
|
||||
): BlePodSnapshot = object : BlePodSnapshot {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(UUID.randomUUID())
|
||||
override val model: PodModel = PodModel.UNKNOWN
|
||||
override val seenLastAt: Instant = seenFirstAt
|
||||
override val seenFirstAt: Instant = seenFirstAt
|
||||
override val seenCounter: Int = 1
|
||||
override val scanResult: BleScanResult = BleScanResult(
|
||||
receivedAt = seenFirstAt,
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
rssi = rssi,
|
||||
generatedAtNanos = 0L,
|
||||
manufacturerSpecificData = emptyMap(),
|
||||
)
|
||||
override val reliability: Float = reliability
|
||||
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
|
||||
override val profile: DeviceProfile? = null
|
||||
}
|
||||
}
|
||||
|
||||
// --- rssiQuality — pure RSSI mapping for display bars ---
|
||||
|
||||
@Test
|
||||
fun `rssiQuality at -30 dBm maps to 1_0 (excellent, 4 bars)`() {
|
||||
fakeSnapshot(rssi = -30).rssiQuality shouldBe 1.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality at -50 dBm maps to ~0_71 (3 bars)`() {
|
||||
fakeSnapshot(rssi = -50).rssiQuality shouldBe (0.714f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality at -65 dBm maps to 0_50 (2 bars)`() {
|
||||
fakeSnapshot(rssi = -65).rssiQuality shouldBe (0.50f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality at -85 dBm maps to ~0_21 (1 bar)`() {
|
||||
fakeSnapshot(rssi = -85).rssiQuality shouldBe (0.214f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality at -100 dBm maps to 0_0 (edge of range)`() {
|
||||
fakeSnapshot(rssi = -100).rssiQuality shouldBe 0.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality clamps lower bound for weaker than -100 dBm`() {
|
||||
fakeSnapshot(rssi = -150).rssiQuality shouldBe 0.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rssiQuality clamps upper bound for stronger than -30 dBm`() {
|
||||
fakeSnapshot(rssi = 0).rssiQuality shouldBe 1.0f
|
||||
fakeSnapshot(rssi = -10).rssiQuality shouldBe 1.0f
|
||||
}
|
||||
|
||||
// --- signalQuality — composite for matching/sorting ---
|
||||
// Formula: (sqRssi + max(BASE, reliability) + sqAge) / 2
|
||||
// BASE_CONFIDENCE = 0, age=0 for fresh snapshots
|
||||
|
||||
@Test
|
||||
fun `signalQuality at -30 dBm cold start (reliability=0, age=0) equals rssiQuality half`() {
|
||||
// (1.0 + 0 + 0) / 2 = 0.5
|
||||
fakeSnapshot(rssi = -30, reliability = 0f).signalQuality shouldBe (0.5f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signalQuality at -100 dBm cold start is 0`() {
|
||||
// (0 + 0 + 0) / 2 = 0
|
||||
fakeSnapshot(rssi = -100, reliability = 0f).signalQuality shouldBe 0.0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signalQuality at -30 dBm established (reliability=0_85) approaches 0_925`() {
|
||||
// (1.0 + 0.85 + 0) / 2 = 0.925
|
||||
fakeSnapshot(rssi = -30, reliability = 0.85f).signalQuality shouldBe (0.925f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signalQuality at -50 dBm established (reliability=0_85) approaches 0_78`() {
|
||||
// (0.714 + 0.85 + 0) / 2 = 0.782
|
||||
fakeSnapshot(rssi = -50, reliability = 0.85f).signalQuality shouldBe (0.782f plusOrMinus 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signalQuality at -100 dBm with reliability 0 stays below AppleFactory default 0_15 threshold`() {
|
||||
// Verify filter behavior: a weak-signal fresh scan is still filtered out
|
||||
val q = fakeSnapshot(rssi = -100, reliability = 0f).signalQuality
|
||||
(q < 0.15f) shouldBe true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user