feat: Add MonitoredDevice facade and DeviceMonitor merge point

MonitoredDevice unifies BLE and AAP data sources with dynamic resolution.

DeviceMonitor combines PodMonitor + AapConnectionManager into a single Flow.

To be renamed to PodDevice/PodMonitor when the old types are renamed to BlePodSnapshot/BlePodMonitor.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent 28cd3b1340
commit f751cdb37e
3 changed files with 184 additions and 0 deletions
@@ -0,0 +1,31 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
import javax.inject.Singleton
/**
* Single merge point: combines BLE scan data ([PodMonitor]) with AAP connection data
* ([AapConnectionManager]) into unified [MonitoredDevice] objects.
*
* ViewModels should observe [devices] instead of accessing PodMonitor directly.
*
* TODO: Rename to PodMonitor once old PodMonitor is renamed to BlePodMonitor.
*/
@Singleton
class DeviceMonitor @Inject constructor(
private val podMonitor: PodMonitor,
private val aapManager: AapConnectionManager,
) {
val devices: Flow<List<MonitoredDevice>> = podMonitor.devices
.combine(aapManager.allStates) { pods, aapStates ->
pods.map { pod ->
MonitoredDevice(
ble = pod,
aap = aapStates[pod.address],
)
}
}
}
@@ -0,0 +1,62 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
/**
* Unified device facade combining BLE scan data and AAP connection data.
* All properties are dynamically resolved from the best available source.
* Consumers don't need to know whether data came from BLE or AAP.
*
* TODO: Rename to PodDevice once old PodDevice interface is renamed to BlePodSnapshot.
*/
data class MonitoredDevice(
internal val ble: PodDevice?,
internal val aap: AapPodState?,
) {
// Identity
val model: PodDevice.Model get() = ble?.model ?: PodDevice.Model.UNKNOWN
val address: BluetoothAddress? get() = ble?.address
// Capabilities from Model.Features
val hasCase: Boolean get() = model.features.hasCase
val hasDualPods: Boolean get() = model.features.hasDualPods
val hasEarDetection: Boolean get() = model.features.hasEarDetection
val hasAncControl: Boolean get() = model.features.hasAncControl
// Battery — best available source
val batteryLeft: Float?
get() = (ble as? DualPodDevice)?.batteryLeftPodPercent
val batteryRight: Float?
get() = (ble as? DualPodDevice)?.batteryRightPodPercent
val batteryCase: Float?
get() = (ble as? HasCase)?.batteryCasePercent
val batteryHeadset: Float?
get() = (ble as? SinglePodDevice)?.batteryHeadsetPercent
// State
val isLeftInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isLeftPodInEar
val isRightInEar: Boolean?
get() = (ble as? HasEarDetectionDual)?.isRightPodInEar
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
// AAP controls
val ancMode: AapSetting.AncMode?
get() = aap?.setting()
val isAapConnected: Boolean get() = aap != null
}
@@ -0,0 +1,91 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionState
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import eu.darken.capod.pods.core.apple.protocol.aap.AncModeValue
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.Test
import testhelpers.BaseTest
class MonitoredDeviceTest : BaseTest() {
private fun mockDualPod(
model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO3,
leftBattery: Float? = 0.8f,
rightBattery: Float? = 0.9f,
caseBattery: Float? = 0.5f,
): PodDevice {
// DualApplePods implements DualPodDevice + HasCase + other interfaces
return mockk<DualApplePods>(relaxed = true) {
every { this@mockk.model } returns model
every { batteryLeftPodPercent } returns leftBattery
every { batteryRightPodPercent } returns rightBattery
every { batteryCasePercent } returns caseBattery
}
}
@Test
fun `BLE-only device exposes battery from BLE`() {
val device = MonitoredDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null)
device.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe false
}
@Test
fun `capabilities come from model features`() {
val device = MonitoredDevice(
ble = mockDualPod(model = PodDevice.Model.AIRPODS_PRO3),
aap = null,
)
device.hasDualPods shouldBe true
device.hasCase shouldBe true
device.hasEarDetection shouldBe true
device.hasAncControl shouldBe true
}
@Test
fun `Beats Solo 3 has no dual pods or case`() {
val device = MonitoredDevice(
ble = mockk(relaxed = true) { every { model } returns PodDevice.Model.BEATS_SOLO_3 },
aap = null,
)
device.hasDualPods shouldBe false
device.hasCase shouldBe false
}
@Test
fun `AAP settings are available when connected`() {
val aap = AapPodState(
connectionState = AapConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
AncModeValue.TRANSPARENCY,
listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY, AncModeValue.ADAPTIVE),
),
),
)
val device = MonitoredDevice(ble = mockDualPod(), aap = aap)
device.isAapConnected shouldBe true
device.ancMode.shouldNotBeNull()
device.ancMode!!.current shouldBe AncModeValue.TRANSPARENCY
}
@Test
fun `ANC mode is null when not AAP connected`() {
val device = MonitoredDevice(ble = mockDualPod(), aap = null)
device.ancMode.shouldBeNull()
}
@Test
fun `null BLE gives UNKNOWN model`() {
val device = MonitoredDevice(ble = null, aap = null)
device.model shouldBe PodDevice.Model.UNKNOWN
}
}