From f751cdb37e17f41e6c75231def88061f7e994c49 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 30 Mar 2026 02:25:24 +0200 Subject: [PATCH] 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. --- .../capod/monitor/core/DeviceMonitor.kt | 31 +++++++ .../capod/monitor/core/MonitoredDevice.kt | 62 +++++++++++++ .../capod/monitor/core/MonitoredDeviceTest.kt | 91 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt new file mode 100644 index 00000000..d5c29893 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -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> = podMonitor.devices + .combine(aapManager.allStates) { pods, aapStates -> + pods.map { pod -> + MonitoredDevice( + ble = pod, + aap = aapStates[pod.address], + ) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt new file mode 100644 index 00000000..55808888 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt @@ -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 +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt new file mode 100644 index 00000000..658081a1 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt @@ -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(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 + } +}