mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user