refactor: Merge device state persistence into DeviceMonitor

Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService.
This commit is contained in:
darken
2026-04-02 16:36:12 +02:00
parent 24a84a49f1
commit 942e03af36
5 changed files with 68 additions and 256 deletions
+1
View File
@@ -13,6 +13,7 @@ import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CoroutineScope
@@ -1,12 +1,23 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.CachedDeviceState.CachedBatterySlot
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
@@ -15,10 +26,12 @@ import javax.inject.Singleton
* ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects.
*
* Includes cached-only devices for profiles that have cached state but no live BLE data.
* Persists live device state to [DeviceStateCache] as a side effect.
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/
@Singleton
class DeviceMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
private val deviceStateCache: DeviceStateCache,
@@ -55,6 +68,58 @@ class DeviceMonitor @Inject constructor(
liveDevices + cachedOnlyDevices
}
.onEach { devices -> persistLiveDevices(devices) }
.replayingShare(appScope)
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 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)" }
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)" }
@@ -1,86 +0,0 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.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 kotlinx.coroutines.flow.Flow
import java.time.Duration
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists combined device state (battery, charging, model) to [DeviceStateCache].
* Reads from raw BLE/AAP sources to avoid re-persisting cached values.
*/
@Singleton
class DeviceStatePersister @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val deviceStateCache: DeviceStateCache,
) {
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices ->
for (device in devices) {
if (!device.isLive) continue
val profileId = device.profileId ?: continue
val now = Instant.now()
val existing = deviceStateCache.cachedStates.value[profileId]
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
// At least one live battery must be non-null to be worth persisting
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})" }
deviceStateCache.save(profileId, newState)
}
}
.map { }
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean {
if (old == null) return false
// Update lastSeenAt periodically so the staleness label stays fresh when device goes offline
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
}
companion object {
private val TAG = logTag("Monitor", "DeviceStatePersister")
}
}
@@ -36,7 +36,7 @@ import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.reaction.core.aap.AapAutoConnect
import eu.darken.capod.monitor.core.DeviceStatePersister
import eu.darken.capod.monitor.core.aap.AapKeyPersister
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
@@ -78,7 +78,7 @@ class MonitorService : Service() {
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapAutoConnect: AapAutoConnect
@Inject lateinit var aapKeyPersister: AapKeyPersister
@Inject lateinit var deviceStatePersister: DeviceStatePersister
@Inject lateinit var aapConnectionManager: AapConnectionManager
private val monitorScope = MonitorCoroutineScope()
@@ -309,11 +309,6 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
deviceStatePersister.monitor()
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
.catch { log(TAG, WARN) { "deviceStatePersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }