mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(ui): Fix signal bars and restore individual connection icons
This commit is contained in:
@@ -102,7 +102,7 @@ fun DualPodsCard(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.signalQuality,
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
@@ -131,8 +131,7 @@ fun DualPodsCard(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DeviceConnectionBadge(
|
||||
bleKeyState = device.bleKeyState,
|
||||
isAapConnected = device.isAapConnected,
|
||||
state = device.connectionState,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Bluetooth
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.Key
|
||||
import androidx.compose.material.icons.twotone.KeyboardVoice
|
||||
import androidx.compose.material.icons.twotone.LinkOff
|
||||
import androidx.compose.material.icons.twotone.Lock
|
||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
@@ -55,6 +56,7 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.monitor.core.BleKeyState
|
||||
import eu.darken.capod.monitor.core.ConnectionState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
private val CapsuleShape = RoundedCornerShape(6.dp)
|
||||
@@ -244,11 +246,10 @@ fun SignalIndicator(
|
||||
|
||||
@Composable
|
||||
fun DeviceConnectionBadge(
|
||||
bleKeyState: BleKeyState,
|
||||
isAapConnected: Boolean,
|
||||
state: ConnectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (bleKeyState == BleKeyState.NONE && !isAapConnected) return
|
||||
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -257,20 +258,33 @@ fun DeviceConnectionBadge(
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
if (bleKeyState != BleKeyState.NONE) {
|
||||
if (state.hasBleData) {
|
||||
Icon(
|
||||
imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key,
|
||||
contentDescription = stringResource(
|
||||
if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd
|
||||
else R.string.signal_badge_key_irk_cd
|
||||
),
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = stringResource(R.string.signal_badge_ble_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (isAapConnected) Spacer(modifier = Modifier.width(3.dp))
|
||||
}
|
||||
if (isAapConnected) {
|
||||
if (state.bleKeyState != BleKeyState.NONE) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Key,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Lock,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.isAapConnected) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Bluetooth,
|
||||
contentDescription = stringResource(R.string.signal_badge_aap_cd),
|
||||
@@ -367,20 +381,67 @@ fun ConversationAwarenessToggle(
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeIrkPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_ONLY, isAapConnected = false)
|
||||
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 1f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true)
|
||||
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.7f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_ONLY,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.5f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(bleKeyState = BleKeyState.NONE, isAapConnected = false)
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
|
||||
@@ -112,7 +112,7 @@ fun SinglePodsCard(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.signalQuality,
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
@@ -130,8 +130,7 @@ fun SinglePodsCard(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DeviceConnectionBadge(
|
||||
bleKeyState = device.bleKeyState,
|
||||
isAapConnected = device.isAapConnected,
|
||||
state = device.connectionState,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ fun UnknownPodDeviceCard(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.signalQuality,
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import eu.darken.capod.monitor.core.cache.toCachedState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -61,6 +62,7 @@ class DeviceMonitor @Inject constructor(
|
||||
cached = profile?.id?.let { cachedStates[it] },
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,6 +87,7 @@ class DeviceMonitor @Inject constructor(
|
||||
cached = cached,
|
||||
profileAddress = profile.address,
|
||||
profileModel = profile.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,6 +158,7 @@ class DeviceMonitor @Inject constructor(
|
||||
cached = cached,
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +170,16 @@ class DeviceMonitor @Inject constructor(
|
||||
private fun Map<BluetoothAddress, AapPodState>.forProfile(profile: DeviceProfile?): AapPodState? =
|
||||
profile?.address?.let { this[it] }
|
||||
|
||||
/**
|
||||
* Derives the stable badge key state from the profile's stored IRK/ENC. Used so the badge
|
||||
* icons don't evaporate every time the BLE scanner misses a scan batch.
|
||||
*/
|
||||
private fun DeviceProfile?.toBleKeyState(): BleKeyState {
|
||||
val apple = this as? AppleDeviceProfile ?: return BleKeyState.NONE
|
||||
if (apple.identityKey == null) return BleKeyState.NONE
|
||||
return if (apple.encryptionKey != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceMonitor")
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ data class PodDevice(
|
||||
internal val profileAddress: BluetoothAddress? = null,
|
||||
/** Pod model from the profile — current source of truth, preferred over cache snapshot. */
|
||||
internal val profileModel: PodModel? = null,
|
||||
/**
|
||||
* Key state derived from the profile's stored IRK/ENC. Used as the source of truth for the
|
||||
* badge icons when the device is live — per-scan IRK matching would otherwise evaporate
|
||||
* every time the BLE scanner misses the next advertisement batch.
|
||||
*/
|
||||
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
|
||||
) {
|
||||
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
|
||||
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||
@@ -65,8 +71,35 @@ data class PodDevice(
|
||||
val bleQuality = ble?.signalQuality ?: 0f
|
||||
return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality value for the signal bar display. Prefers live BLE RSSI; when BLE is absent
|
||||
* (common for a device that's actively connected to us via classic BT — we stop receiving
|
||||
* its BLE advertisements), falls back to a full-bars constant as long as AAP is READY.
|
||||
*
|
||||
* Note: we don't try to graduate this off AAP message age. The AirPods only push messages
|
||||
* on state changes (battery %, ear detection, setting toggles, user taps). A user quietly
|
||||
* listening to music can go minutes without any AAP traffic, which is still a perfectly
|
||||
* healthy connection. A quiet channel is not a weak channel.
|
||||
*/
|
||||
val rssiQuality: Float
|
||||
get() {
|
||||
ble?.rssiQuality?.let { return it }
|
||||
val state = aap ?: return 0f
|
||||
return if (state.connectionState == AapPodState.ConnectionState.READY) 1.0f else 0f
|
||||
}
|
||||
|
||||
val rssi: Int get() = ble?.rssi ?: 0
|
||||
|
||||
/** Snapshot of connection-related state for badges and bars. */
|
||||
val connectionState: ConnectionState
|
||||
get() = ConnectionState(
|
||||
hasBleData = ble != null,
|
||||
bleKeyState = bleKeyState,
|
||||
isAapConnected = isAapConnected,
|
||||
rssiQuality = rssiQuality,
|
||||
)
|
||||
|
||||
internal fun computeAapBoost(now: Instant): Float {
|
||||
val state = aap ?: return 0f
|
||||
if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f
|
||||
@@ -291,8 +324,16 @@ data class PodDevice(
|
||||
|
||||
val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY
|
||||
|
||||
/**
|
||||
* Badge key state for the overview card. Profile-stored keys are the source of truth while
|
||||
* the device is live: a profile that has an IRK (and optionally ENC) keeps showing those
|
||||
* icons whether the current scan happens to include a fresh BLE advertisement or not. For
|
||||
* anonymous BLE pods (no profile), we still derive the state from the live scan result.
|
||||
*/
|
||||
val bleKeyState: BleKeyState
|
||||
get() {
|
||||
if (!isLive) return BleKeyState.NONE
|
||||
if (profileKeyState != BleKeyState.NONE) return profileKeyState
|
||||
val applePod = ble as? ApplePods ?: return BleKeyState.NONE
|
||||
if (!applePod.meta.isIRKMatch) return BleKeyState.NONE
|
||||
return if (applePod.payload.private != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
@@ -300,3 +341,10 @@ data class PodDevice(
|
||||
}
|
||||
|
||||
enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED }
|
||||
|
||||
data class ConnectionState(
|
||||
val hasBleData: Boolean,
|
||||
val bleKeyState: BleKeyState,
|
||||
val isAapConnected: Boolean,
|
||||
val rssiQuality: Float,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
interface BlePodSnapshot {
|
||||
@@ -36,13 +35,23 @@ interface BlePodSnapshot {
|
||||
|
||||
val reliability: Float
|
||||
|
||||
/**
|
||||
* Pure RSSI quality for display bars — linear map of -100..-30 dBm → 0..1.
|
||||
* Does not include reliability/age. Use for SignalIndicator only, not for
|
||||
* device matching or sorting (that's signalQuality's job).
|
||||
*/
|
||||
val rssiQuality: Float
|
||||
get() = ((rssi + 100) / 70f).coerceIn(0f, 1f)
|
||||
|
||||
/**
|
||||
* Composite quality for profile matching and sorting. Weighted blend of
|
||||
* RSSI strength, detection reliability, and observation age.
|
||||
* Used by AppleFactory (minimumSignalQuality filter) and TroubleShooter
|
||||
* (closest-device selection). Not used for display bars.
|
||||
*/
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
/**
|
||||
* This is not correct but it works ¯\_(ツ)_/¯
|
||||
* The range of the RSSI is device specific (ROMs).
|
||||
*/
|
||||
val sqRssi = ((100 - abs(rssi)) / 100f)
|
||||
val sqRssi = ((rssi + 100) / 70f).coerceIn(0f, 1f)
|
||||
val sqReliability = max(BASE_CONFIDENCE, reliability)
|
||||
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
|
||||
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
|
||||
|
||||
@@ -75,7 +75,7 @@ fun PopUpContent(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.signalQuality,
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
<string name="anc_mode_transparency">Transparency</string>
|
||||
<string name="anc_mode_adaptive">Adaptive</string>
|
||||
<string name="conversation_awareness_label">Conversation Awareness</string>
|
||||
<string name="signal_badge_ble_cd">BLE scan active</string>
|
||||
<string name="signal_badge_key_irk_cd">Identity verified</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Encrypted connection</string>
|
||||
<string name="signal_badge_aap_cd">Direct connection active</string>
|
||||
|
||||
@@ -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