mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
refactor: Extract toCachedState extension and add tests
Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter.
This commit is contained in:
@@ -6,21 +6,15 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||
import eu.darken.capod.monitor.core.cache.toCachedState
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -76,54 +70,15 @@ class DeviceMonitor @Inject constructor(
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
if (!device.isLive) continue
|
||||
val profileId = device.profileId ?: continue
|
||||
|
||||
val now = Instant.now()
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.toCachedState(existing) ?: continue
|
||||
|
||||
val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = profileId,
|
||||
model = device.model,
|
||||
address = device.address,
|
||||
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
|
||||
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
|
||||
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
|
||||
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
|
||||
isLeftCharging = device.isLeftPodCharging,
|
||||
isRightCharging = device.isRightPodCharging,
|
||||
isCaseCharging = device.isCaseCharging,
|
||||
isHeadsetCharging = device.isHeadsetBeingCharged,
|
||||
lastSeenAt = device.seenLastAt ?: now,
|
||||
)
|
||||
|
||||
if (isSameState(existing, newState)) continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId (L=$liveLeft R=$liveRight C=$liveCase H=$liveHeadset)" }
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean {
|
||||
if (old == null) return false
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false
|
||||
return old.left?.percent == new.left?.percent
|
||||
&& old.right?.percent == new.right?.percent
|
||||
&& old.case?.percent == new.case?.percent
|
||||
&& old.headset?.percent == new.headset?.percent
|
||||
&& old.isLeftCharging == new.isLeftCharging
|
||||
&& old.isRightCharging == new.isRightCharging
|
||||
&& old.isCaseCharging == new.isCaseCharging
|
||||
&& old.isHeadsetCharging == new.isHeadsetCharging
|
||||
}
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
|
||||
@@ -70,3 +70,4 @@ fun PodDevice.cachedBatteryFormatted(now: Instant): String {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import dagger.Reusable
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class PodSorter @Inject constructor(
|
||||
|
||||
)
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Creates a [CachedDeviceState] from this live device's raw BLE/AAP battery data.
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - The device has no profile
|
||||
* - All live battery values are null
|
||||
* - The state hasn't changed from [existing] (dedup)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
existing: CachedDeviceState?,
|
||||
now: Instant = Instant.now(),
|
||||
): CachedDeviceState? {
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
address = address,
|
||||
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
|
||||
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
|
||||
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
|
||||
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
|
||||
isLeftCharging = isLeftPodCharging,
|
||||
isRightCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetCharging = isHeadsetBeingCharged,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
if (existing != null && !hasStateChanged(existing, newState)) return null
|
||||
|
||||
return newState
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
|
||||
class ToCachedStateTest : BaseTest() {
|
||||
|
||||
private val now = Instant.parse("2026-04-02T12:00:00Z")
|
||||
|
||||
private fun mockDualPod(
|
||||
leftBattery: Float? = null,
|
||||
rightBattery: Float? = null,
|
||||
caseBattery: Float? = null,
|
||||
): DualApplePods = mockk(relaxed = true) {
|
||||
every { batteryLeftPodPercent } returns leftBattery
|
||||
every { batteryRightPodPercent } returns rightBattery
|
||||
every { batteryCasePercent } returns caseBattery
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { seenLastAt } returns now
|
||||
}
|
||||
|
||||
private fun liveDevice(
|
||||
leftBattery: Float? = 0.8f,
|
||||
rightBattery: Float? = 0.7f,
|
||||
caseBattery: Float? = 0.5f,
|
||||
) = PodDevice(
|
||||
profileId = "test-profile",
|
||||
ble = mockDualPod(leftBattery, rightBattery, caseBattery),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
@Nested
|
||||
inner class Creates {
|
||||
|
||||
@Test
|
||||
fun `creates cached state from live device with battery`() {
|
||||
val result = liveDevice().toCachedState(existing = null, now = now)
|
||||
result.shouldNotBeNull()
|
||||
result.profileId shouldBe "test-profile"
|
||||
result.left?.percent shouldBe 0.8f
|
||||
result.right?.percent shouldBe 0.7f
|
||||
result.case?.percent shouldBe 0.5f
|
||||
result.lastSeenAt shouldBe now
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preserves existing slots when live value is null`() {
|
||||
val existing = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.9f, now.minusSeconds(60)),
|
||||
lastSeenAt = now.minusSeconds(120),
|
||||
)
|
||||
val result = liveDevice(leftBattery = null, caseBattery = 0.6f).toCachedState(existing, now)
|
||||
result.shouldNotBeNull()
|
||||
result.left?.percent shouldBe 0.9f
|
||||
result.case?.percent shouldBe 0.6f
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Skips {
|
||||
|
||||
@Test
|
||||
fun `returns null for cached-only device`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = mockk(relaxed = true))
|
||||
device.toCachedState(existing = null, now = now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when no profile`() {
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(caseBattery = 0.5f), aap = null)
|
||||
device.toCachedState(existing = null, now = now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when all battery values null`() {
|
||||
liveDevice(leftBattery = null, rightBattery = null, caseBattery = null)
|
||||
.toCachedState(existing = null, now = now)
|
||||
.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when state unchanged`() {
|
||||
val existing = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
|
||||
right = CachedDeviceState.CachedBatterySlot(0.7f, now),
|
||||
case = CachedDeviceState.CachedBatterySlot(0.5f, now),
|
||||
isLeftCharging = false,
|
||||
isRightCharging = false,
|
||||
isCaseCharging = false,
|
||||
isHeadsetCharging = false,
|
||||
lastSeenAt = now,
|
||||
)
|
||||
liveDevice().toCachedState(existing, now).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns new state when battery changed`() {
|
||||
val existing = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.9f, now),
|
||||
lastSeenAt = now,
|
||||
)
|
||||
liveDevice(leftBattery = 0.8f).toCachedState(existing, now).shouldNotBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns new state when lastSeenAt drifted over 1 minute`() {
|
||||
val existing = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.8f, now.minusSeconds(120)),
|
||||
right = CachedDeviceState.CachedBatterySlot(0.7f, now.minusSeconds(120)),
|
||||
case = CachedDeviceState.CachedBatterySlot(0.5f, now.minusSeconds(120)),
|
||||
isLeftCharging = false,
|
||||
isRightCharging = false,
|
||||
isCaseCharging = false,
|
||||
isHeadsetCharging = false,
|
||||
lastSeenAt = now.minusSeconds(120),
|
||||
)
|
||||
liveDevice().toCachedState(existing, now).shouldNotBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user