mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
Refactor packages
This commit is contained in:
+219
@@ -0,0 +1,219 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.twotone.BatteryUnknown
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompactBatterySummary(
|
||||||
|
device: PodDevice,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val hasAnyBattery = device.batteryLeft != null
|
||||||
|
|| device.batteryRight != null
|
||||||
|
|| device.batteryHeadset != null
|
||||||
|
|| device.batteryCase != null
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
!hasAnyBattery -> EmptyBatteryRow()
|
||||||
|
device.hasDualPods -> DualPodsRow(device)
|
||||||
|
else -> SinglePodRow(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.DualPodsRow(device: PodDevice) {
|
||||||
|
MiniPodRing(
|
||||||
|
iconRes = device.leftPodIcon,
|
||||||
|
percent = device.batteryLeft,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
MiniPodRing(
|
||||||
|
iconRes = device.rightPodIcon,
|
||||||
|
percent = device.batteryRight,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (device.hasCase && device.batteryCase != null) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
MiniCaseCluster(device = device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SinglePodRow(device: PodDevice) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
MiniPodRing(
|
||||||
|
iconRes = null,
|
||||||
|
percent = device.batteryHeadset,
|
||||||
|
)
|
||||||
|
if (device.hasCase && device.batteryCase != null) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
MiniCaseCluster(device = device)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.EmptyBatteryRow() {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.TwoTone.BatteryUnknown,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.battery_unavailable_label),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MiniPodRing(
|
||||||
|
iconRes: Int?,
|
||||||
|
percent: Float?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clamped = percent?.coerceIn(0f, 1f)
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = clamped ?: 0f,
|
||||||
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
|
label = "miniGaugeProgress",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ringColor = when {
|
||||||
|
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
|
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { 1f },
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
if (clamped != null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
color = ringColor,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (iconRes != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = formatBatteryPercent(context, percent),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (percent != null) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MiniCaseCluster(
|
||||||
|
device: PodDevice,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(device.caseIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
BatteryCapsule(
|
||||||
|
percent = device.batteryCase ?: -1f,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(6.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = formatBatteryPercent(context, device.batteryCase),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ package eu.darken.capod.pods.core.apple.aap
|
|||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
@@ -78,7 +79,7 @@ class AapConnectionManager @Inject constructor(
|
|||||||
// Clear stale intentional-disconnect flag from previous connection lifecycle
|
// Clear stale intentional-disconnect flag from previous connection lifecycle
|
||||||
intentionalDisconnects.remove(address)
|
intentionalDisconnects.remove(address)
|
||||||
|
|
||||||
val profile = AapDeviceProfile.Companion.forModel(model)
|
val profile = AapDeviceProfile.forModel(model)
|
||||||
val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource)
|
val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource)
|
||||||
connections[address] = connection
|
connections[address] = connection
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,6 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
@@ -35,11 +36,11 @@ internal class AapAncController {
|
|||||||
val previous = podState.settings[key]
|
val previous = podState.settings[key]
|
||||||
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
|
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
|
||||||
val timerActions = mutableListOf<EngineTimerAction>()
|
val timerActions = mutableListOf<EngineTimerAction>()
|
||||||
timerActions += planAllowOffInferenceTimer(podState, updatedRuntime)
|
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
|
||||||
|
|
||||||
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null
|
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null
|
||||||
return if (isFirstAncMode || isRecentAncSend) {
|
return if (isFirstAncMode || isRecentAncSend) {
|
||||||
timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce)
|
timerActions.plusAssign(EngineTimerAction.Cancel(EngineTimerKey.AncDebounce))
|
||||||
AncDecision(
|
AncDecision(
|
||||||
podState = applyAncSetting(podState, key, value, now, isRecentAncSend),
|
podState = applyAncSetting(podState, key, value, now, isRecentAncSend),
|
||||||
runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null),
|
runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null),
|
||||||
@@ -47,7 +48,7 @@ internal class AapAncController {
|
|||||||
logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"),
|
logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L)
|
timerActions.plusAssign(EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L))
|
||||||
AncDecision(
|
AncDecision(
|
||||||
podState = podState,
|
podState = podState,
|
||||||
runtimeState = updatedRuntime.copy(
|
runtimeState = updatedRuntime.copy(
|
||||||
+11
-12
@@ -1,15 +1,14 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
|
||||||
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||||
@@ -31,7 +30,7 @@ import java.io.IOException
|
|||||||
/**
|
/**
|
||||||
* Manages a single AAP L2CAP connection to a device.
|
* Manages a single AAP L2CAP connection to a device.
|
||||||
* Thin socket wrapper — all session logic lives in [AapSessionEngine].
|
* Thin socket wrapper — all session logic lives in [AapSessionEngine].
|
||||||
* Internal — not exposed outside [AapConnectionManager].
|
* Internal — not exposed outside [eu.darken.capod.pods.core.apple.aap.AapConnectionManager].
|
||||||
*/
|
*/
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
internal class AapConnection(
|
internal class AapConnection(
|
||||||
@@ -67,7 +66,7 @@ internal class AapConnection(
|
|||||||
val sock = socketFactory.createSocket(device, PSM)
|
val sock = socketFactory.createSocket(device, PSM)
|
||||||
sock.connect()
|
sock.connect()
|
||||||
socket = sock
|
socket = sock
|
||||||
log(TAG, INFO) { "Connected to ${device.address}" }
|
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||||
|
|
||||||
engine.onHandshakeSent()
|
engine.onHandshakeSent()
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ internal class AapConnection(
|
|||||||
// Launch read loop in the provided scope — connect() returns immediately
|
// Launch read loop in the provided scope — connect() returns immediately
|
||||||
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "Connection failed: $e" }
|
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
|
||||||
cleanupSocket()
|
cleanupSocket()
|
||||||
engine.reset()
|
engine.reset()
|
||||||
throw e
|
throw e
|
||||||
@@ -107,7 +106,7 @@ internal class AapConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||||
log(TAG, INFO) { "Disconnecting" }
|
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
|
||||||
readerJob?.cancel()
|
readerJob?.cancel()
|
||||||
readerJob = null
|
readerJob = null
|
||||||
engine.reset()
|
engine.reset()
|
||||||
@@ -127,7 +126,7 @@ internal class AapConnection(
|
|||||||
sock.outputStream.write(bytes)
|
sock.outputStream.write(bytes)
|
||||||
sock.outputStream.flush()
|
sock.outputStream.flush()
|
||||||
val hex = bytes.joinToString(" ") { "%02X".format(it) }
|
val hex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||||
log(TAG, VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
|
log(TAG, Logging.Priority.VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,13 +144,13 @@ internal class AapConnection(
|
|||||||
|
|
||||||
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
||||||
val raw = buf.copyOfRange(0, len)
|
val raw = buf.copyOfRange(0, len)
|
||||||
val message = AapMessage.Companion.parse(raw)
|
val message = AapMessage.parse(raw)
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
engine.processMessage(message)
|
engine.processMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
if (isActive) log(TAG, ERROR) { "Read error: $e" }
|
if (isActive) log(TAG, Logging.Priority.ERROR) { "Read error: $e" }
|
||||||
} finally {
|
} finally {
|
||||||
engine.reset()
|
engine.reset()
|
||||||
cleanupSocket()
|
cleanupSocket()
|
||||||
@@ -170,4 +169,4 @@ internal class AapConnection(
|
|||||||
private const val PSM = 0x1001
|
private const val PSM = 0x1001
|
||||||
private val TAG = logTag("AAP", "Connection")
|
private val TAG = logTag("AAP", "Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-5
@@ -1,4 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.CodingErrorAction
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
|
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
|
||||||
@@ -28,11 +31,11 @@ internal object AapDeviceInfoDiagnostics {
|
|||||||
|
|
||||||
val utf8: String? = try {
|
val utf8: String? = try {
|
||||||
Charsets.UTF_8.newDecoder()
|
Charsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
.decode(java.nio.ByteBuffer.wrap(segBytes))
|
.decode(ByteBuffer.wrap(segBytes))
|
||||||
.toString()
|
.toString()
|
||||||
} catch (_: java.nio.charset.CharacterCodingException) {
|
} catch (_: CharacterCodingException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||||
+20
-2
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
@@ -95,38 +96,47 @@ internal class AapSettingsCoordinator(
|
|||||||
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null
|
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null
|
||||||
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
|
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetNcWithOneAirPod -> {
|
is AapCommand.SetNcWithOneAirPod -> {
|
||||||
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null
|
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null
|
||||||
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
|
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetVolumeSwipe -> {
|
is AapCommand.SetVolumeSwipe -> {
|
||||||
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null
|
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null
|
||||||
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
|
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetPersonalizedVolume -> {
|
is AapCommand.SetPersonalizedVolume -> {
|
||||||
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null
|
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null
|
||||||
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
|
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetToneVolume -> {
|
is AapCommand.SetToneVolume -> {
|
||||||
baseState.setting<AapSetting.ToneVolume>() ?: return null
|
baseState.setting<AapSetting.ToneVolume>() ?: return null
|
||||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
|
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetAdaptiveAudioNoise -> {
|
is AapCommand.SetAdaptiveAudioNoise -> {
|
||||||
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null
|
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null
|
||||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
|
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetPressSpeed -> {
|
is AapCommand.SetPressSpeed -> {
|
||||||
baseState.setting<AapSetting.PressSpeed>() ?: return null
|
baseState.setting<AapSetting.PressSpeed>() ?: return null
|
||||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
|
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetPressHoldDuration -> {
|
is AapCommand.SetPressHoldDuration -> {
|
||||||
baseState.setting<AapSetting.PressHoldDuration>() ?: return null
|
baseState.setting<AapSetting.PressHoldDuration>() ?: return null
|
||||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
|
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetVolumeSwipeLength -> {
|
is AapCommand.SetVolumeSwipeLength -> {
|
||||||
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null
|
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null
|
||||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
|
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetEndCallMuteMic -> {
|
is AapCommand.SetEndCallMuteMic -> {
|
||||||
baseState.setting<AapSetting.EndCallMuteMic>() ?: return null
|
baseState.setting<AapSetting.EndCallMuteMic>() ?: return null
|
||||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
|
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
|
||||||
@@ -134,24 +144,31 @@ internal class AapSettingsCoordinator(
|
|||||||
endCall = command.endCall,
|
endCall = command.endCall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetMicrophoneMode -> {
|
is AapCommand.SetMicrophoneMode -> {
|
||||||
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
|
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetEarDetectionEnabled -> {
|
is AapCommand.SetEarDetectionEnabled -> {
|
||||||
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
|
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetListeningModeCycle -> {
|
is AapCommand.SetListeningModeCycle -> {
|
||||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
|
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetAllowOffOption -> {
|
is AapCommand.SetAllowOffOption -> {
|
||||||
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
|
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetStemConfig -> {
|
is AapCommand.SetStemConfig -> {
|
||||||
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
|
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetSleepDetection -> {
|
is AapCommand.SetSleepDetection -> {
|
||||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
|
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetDeviceName -> {
|
is AapCommand.SetDeviceName -> {
|
||||||
val currentInfo = baseState.deviceInfo ?: return null
|
val currentInfo = baseState.deviceInfo ?: return null
|
||||||
return baseState.copy(
|
return baseState.copy(
|
||||||
@@ -178,6 +195,7 @@ internal class AapSettingsCoordinator(
|
|||||||
val cur = s.setting<AapSetting.EndCallMuteMic>()
|
val cur = s.setting<AapSetting.EndCallMuteMic>()
|
||||||
cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall
|
cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall
|
||||||
}
|
}
|
||||||
|
|
||||||
is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode }
|
is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode }
|
||||||
is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled }
|
is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled }
|
||||||
is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask }
|
is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask }
|
||||||
@@ -186,4 +204,4 @@ internal class AapSettingsCoordinator(
|
|||||||
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
||||||
is AapCommand.SetDeviceName -> null
|
is AapCommand.SetDeviceName -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-2
@@ -1,4 +1,4 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batches cmd 0x0017 HID descriptor frames and emits structured summaries.
|
* Batches cmd 0x0017 HID descriptor frames and emits structured summaries.
|
||||||
@@ -21,6 +21,7 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
|||||||
val names = type.services.joinToString(", ")
|
val names = type.services.joinToString(", ")
|
||||||
log("HID: services=[$names] (${payload.size}B)")
|
log("HID: services=[$names] (${payload.size}B)")
|
||||||
}
|
}
|
||||||
|
|
||||||
is HidFrameType.Descriptor -> {
|
is HidFrameType.Descriptor -> {
|
||||||
if (bulkCount == 0) {
|
if (bulkCount == 0) {
|
||||||
bulkPhase = type.phase
|
bulkPhase = type.phase
|
||||||
@@ -35,10 +36,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
|||||||
bulkCount = 1
|
bulkCount = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HidFrameType.Terminator -> {
|
is HidFrameType.Terminator -> {
|
||||||
flush()
|
flush()
|
||||||
log("HID: terminator (${type.payloadSize}B)")
|
log("HID: terminator (${type.payloadSize}B)")
|
||||||
}
|
}
|
||||||
|
|
||||||
is HidFrameType.Other -> {
|
is HidFrameType.Other -> {
|
||||||
flush()
|
flush()
|
||||||
val hex = payload.joinToString(" ") { "%02X".format(it) }
|
val hex = payload.joinToString(" ") { "%02X".format(it) }
|
||||||
@@ -111,4 +114,4 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
|||||||
return HidFrameType.Other
|
return HidFrameType.Other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ data class AapMessage(
|
|||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Parse a complete AAP message from raw bytes.
|
* Parse a complete AAP message from raw bytes.
|
||||||
* AAP messages have the format: [4-byte header] [2-byte command type] [payload...]
|
* AAP messages have the format: [4-byte header] [2-byte command type] [payload…]
|
||||||
* Minimum message size is 6 bytes (header + command type with no payload).
|
* Minimum message size is 6 bytes (header + command type with no payload).
|
||||||
*/
|
*/
|
||||||
fun parse(raw: ByteArray): AapMessage? {
|
fun parse(raw: ByteArray): AapMessage? {
|
||||||
|
|||||||
+5
-3
@@ -1,11 +1,13 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import testhelpers.BaseTest
|
import testhelpers.BaseTest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class AapAncControllerTest : BaseTest() {
|
class AapAncControllerTest : BaseTest() {
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ class AapAncControllerTest : BaseTest() {
|
|||||||
AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun podStateWith(vararg settings: Pair<kotlin.reflect.KClass<out AapSetting>, AapSetting>): AapPodState =
|
private fun podStateWith(vararg settings: Pair<KClass<out AapSetting>, AapSetting>): AapPodState =
|
||||||
AapPodState(
|
AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
settings = settings.toMap(),
|
settings = settings.toMap(),
|
||||||
@@ -149,4 +151,4 @@ class AapAncControllerTest : BaseTest() {
|
|||||||
decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false
|
decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false
|
||||||
decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference))
|
decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||||
@@ -15,7 +15,7 @@ import testhelpers.BaseTest
|
|||||||
* The helper must:
|
* The helper must:
|
||||||
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
|
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
|
||||||
* - Decode non-ASCII UTF-8 (emoji, non-Latin script) — the production parser drops these.
|
* - Decode non-ASCII UTF-8 (emoji, non-Latin script) — the production parser drops these.
|
||||||
* - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo]
|
* - Always emit segments for any payload shape, even when [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile.decodeDeviceInfo]
|
||||||
* would return null (malformed / unknown-shaped packets).
|
* would return null (malformed / unknown-shaped packets).
|
||||||
*/
|
*/
|
||||||
class AapDeviceInfoDiagnosticsTest : BaseTest() {
|
class AapDeviceInfoDiagnosticsTest : BaseTest() {
|
||||||
@@ -78,8 +78,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
|
|||||||
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
|
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
|
||||||
val engraving = "My AirPods 🌈"
|
val engraving = "My AirPods 🌈"
|
||||||
val payload = hex("02 DF 00 04 00") +
|
val payload = hex("02 DF 00 04 00") +
|
||||||
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
||||||
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
||||||
|
|
||||||
val segments = AapDeviceInfoDiagnostics.describeSegments(payload)
|
val segments = AapDeviceInfoDiagnostics.describeSegments(payload)
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
|
|||||||
// diagnostic dump must still surface whatever segments it finds so maintainers can inspect
|
// diagnostic dump must still surface whatever segments it finds so maintainers can inspect
|
||||||
// unexpected-shape packets from an engraved device.
|
// unexpected-shape packets from an engraved device.
|
||||||
val payload = hex("02 DF 00 04 00") +
|
val payload = hex("02 DF 00 04 00") +
|
||||||
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
|
||||||
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
|
||||||
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
|
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
|
||||||
val message = AapMessage.parse(fullMessageBytes)!!
|
val message = AapMessage.parse(fullMessageBytes)!!
|
||||||
|
|
||||||
@@ -106,4 +106,4 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
|
|||||||
segments[0].utf8 shouldBe "Name"
|
segments[0].utf8 shouldBe "Name"
|
||||||
segments[1].utf8 shouldBe "Model"
|
segments[1].utf8 shouldBe "Model"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+255
-210
@@ -1,11 +1,11 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||||
import io.kotest.matchers.collections.shouldBeEmpty
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
@@ -131,22 +131,28 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
engine.start(this as TestScope)
|
engine.start(this as TestScope)
|
||||||
engine.onHandshakeSent()
|
engine.onHandshakeSent()
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
nextSetting = settingPair(
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
AapSetting.EarDetection(
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
))
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
nextSetting = settingPair(
|
||||||
current = AapSetting.AncMode.Value.ON,
|
AapSetting.AncMode(
|
||||||
supported = supportedModes,
|
current = AapSetting.AncMode.Value.ON,
|
||||||
))
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
nextSetting = settingPair(
|
||||||
current = AapSetting.AncMode.Value.TRANSPARENCY,
|
AapSetting.AncMode(
|
||||||
supported = supportedModes,
|
current = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||||
))
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
engine.reset()
|
engine.reset()
|
||||||
@@ -243,67 +249,68 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() = runTest(UnconfinedTestDispatcher()) {
|
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() =
|
||||||
val ancSetting = AapSetting.AncMode(
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
current = AapSetting.AncMode.Value.ON,
|
val ancSetting = AapSetting.AncMode(
|
||||||
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE),
|
current = AapSetting.AncMode.Value.ON,
|
||||||
)
|
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE),
|
||||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
)
|
||||||
val profile = mockProfile {
|
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||||
every { decodeSetting(any()) } answers { nextSetting }
|
val profile = mockProfile {
|
||||||
|
every { decodeSetting(any()) } answers { nextSetting }
|
||||||
|
}
|
||||||
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
|
engine.startReady(this as TestScope)
|
||||||
|
|
||||||
|
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
nextSetting =
|
||||||
|
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
|
||||||
|
AapSetting.ConversationalAwareness(enabled = false)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
val sentCommands = mutableListOf<AapCommand>()
|
||||||
|
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
|
||||||
|
|
||||||
|
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
|
||||||
|
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
|
||||||
|
|
||||||
|
engine.state.value.pendingSettingsCount shouldBe 2
|
||||||
|
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
|
||||||
|
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
sentCommands.size shouldBe 2
|
||||||
|
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
||||||
|
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
|
||||||
|
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
|
||||||
|
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
|
||||||
|
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
|
||||||
|
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||||
|
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
|
||||||
|
advanceTimeBy(1100L)
|
||||||
|
|
||||||
|
sentCommands.size shouldBe 3
|
||||||
|
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
||||||
|
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
}
|
}
|
||||||
val engine = AapSessionEngine(profile, timeSource)
|
|
||||||
engine.startReady(this as TestScope)
|
|
||||||
|
|
||||||
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
|
||||||
)
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
nextSetting =
|
|
||||||
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
|
|
||||||
AapSetting.ConversationalAwareness(enabled = false)
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
val sentCommands = mutableListOf<AapCommand>()
|
|
||||||
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
|
|
||||||
|
|
||||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
|
|
||||||
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
|
|
||||||
|
|
||||||
engine.state.value.pendingSettingsCount shouldBe 2
|
|
||||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
|
|
||||||
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
|
||||||
)
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
runCurrent()
|
|
||||||
|
|
||||||
sentCommands.size shouldBe 2
|
|
||||||
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
|
||||||
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
|
|
||||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
|
|
||||||
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
|
|
||||||
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
|
|
||||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
|
||||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
|
|
||||||
advanceTimeBy(1100L)
|
|
||||||
|
|
||||||
sentCommands.size shouldBe 3
|
|
||||||
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
|
||||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Message processing — state merge ────────────────────
|
// ── Message processing — state merge ────────────────────
|
||||||
@@ -402,43 +409,50 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
inner class InferenceTests {
|
inner class InferenceTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() = runTest(UnconfinedTestDispatcher()) {
|
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() =
|
||||||
val supportedModes = listOf(
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
AapSetting.AncMode.Value.OFF,
|
val supportedModes = listOf(
|
||||||
AapSetting.AncMode.Value.ON,
|
AapSetting.AncMode.Value.OFF,
|
||||||
AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode.Value.ON,
|
||||||
)
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
)
|
||||||
val profile = mockProfile {
|
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||||
every { decodeSetting(any()) } answers { nextSetting }
|
val profile = mockProfile {
|
||||||
|
every { decodeSetting(any()) } answers { nextSetting }
|
||||||
|
}
|
||||||
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
|
engine.start(this as TestScope)
|
||||||
|
engine.onHandshakeSent()
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.OFF,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||||
|
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
advanceTimeBy(1600L)
|
||||||
|
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
advanceTimeBy(1600L)
|
||||||
|
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||||
}
|
}
|
||||||
val engine = AapSessionEngine(profile, timeSource)
|
|
||||||
engine.start(this as TestScope)
|
|
||||||
engine.onHandshakeSent()
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.OFF,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
|
||||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
advanceTimeBy(1600L)
|
|
||||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
advanceTimeBy(1600L)
|
|
||||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `stable in-ear OFF infers AllowOffOption true after delay`() = runTest(UnconfinedTestDispatcher()) {
|
fun `stable in-ear OFF infers AllowOffOption true after delay`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
@@ -455,16 +469,20 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
engine.start(this as TestScope)
|
engine.start(this as TestScope)
|
||||||
engine.onHandshakeSent()
|
engine.onHandshakeSent()
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
nextSetting = settingPair(
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
AapSetting.EarDetection(
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
))
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
nextSetting = settingPair(
|
||||||
current = AapSetting.AncMode.Value.OFF,
|
AapSetting.AncMode(
|
||||||
supported = supportedModes,
|
current = AapSetting.AncMode.Value.OFF,
|
||||||
))
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||||
|
|
||||||
@@ -473,43 +491,50 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() = runTest(UnconfinedTestDispatcher()) {
|
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() =
|
||||||
val supportedModes = listOf(
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
AapSetting.AncMode.Value.OFF,
|
val supportedModes = listOf(
|
||||||
AapSetting.AncMode.Value.ON,
|
AapSetting.AncMode.Value.OFF,
|
||||||
AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode.Value.ON,
|
||||||
)
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
)
|
||||||
val profile = mockProfile {
|
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||||
every { decodeSetting(any()) } answers { nextSetting }
|
val profile = mockProfile {
|
||||||
|
every { decodeSetting(any()) } answers { nextSetting }
|
||||||
|
}
|
||||||
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
|
engine.start(this as TestScope)
|
||||||
|
engine.onHandshakeSent()
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.OFF,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
advanceTimeBy(500L)
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
advanceTimeBy(1600L)
|
||||||
|
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||||
|
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
}
|
}
|
||||||
val engine = AapSessionEngine(profile, timeSource)
|
|
||||||
engine.start(this as TestScope)
|
|
||||||
engine.onHandshakeSent()
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.OFF,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
advanceTimeBy(500L)
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
advanceTimeBy(1600L)
|
|
||||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
|
||||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
|
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
@@ -525,16 +550,20 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
val engine = AapSessionEngine(profile, timeSource)
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
engine.startReady(this as TestScope)
|
engine.startReady(this as TestScope)
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
nextSetting = settingPair(
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
AapSetting.EarDetection(
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
))
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
nextSetting = settingPair(
|
||||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode(
|
||||||
supported = supportedModes,
|
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
))
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
|
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
|
||||||
@@ -543,10 +572,12 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
val sentCommands = mutableListOf<AapCommand>()
|
val sentCommands = mutableListOf<AapCommand>()
|
||||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
|
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
nextSetting = settingPair(
|
||||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode(
|
||||||
supported = supportedModes,
|
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
))
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
advanceTimeBy(2100L)
|
advanceTimeBy(2100L)
|
||||||
@@ -561,47 +592,54 @@ class AapSessionEngineTest : BaseTest() {
|
|||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `matching ANC echo clears pending mode without optimistic current overwrite`() = runTest(UnconfinedTestDispatcher()) {
|
fun `matching ANC echo clears pending mode without optimistic current overwrite`() =
|
||||||
val supportedModes = listOf(
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
AapSetting.AncMode.Value.OFF,
|
val supportedModes = listOf(
|
||||||
AapSetting.AncMode.Value.ON,
|
AapSetting.AncMode.Value.OFF,
|
||||||
AapSetting.AncMode.Value.ADAPTIVE,
|
AapSetting.AncMode.Value.ON,
|
||||||
)
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
)
|
||||||
val profile = mockProfile {
|
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||||
every { decodeSetting(any()) } answers { nextSetting }
|
val profile = mockProfile {
|
||||||
|
every { decodeSetting(any()) } answers { nextSetting }
|
||||||
|
}
|
||||||
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
|
engine.startReady(this as TestScope)
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ON,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
val sentCommands = mutableListOf<AapCommand>()
|
||||||
|
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
|
||||||
|
|
||||||
|
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||||
|
|
||||||
|
nextSetting = settingPair(
|
||||||
|
AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
supported = supportedModes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
|
engine.state.value.pendingAncMode.shouldBeNull()
|
||||||
|
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
|
||||||
}
|
}
|
||||||
val engine = AapSessionEngine(profile, timeSource)
|
|
||||||
engine.startReady(this as TestScope)
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.EarDetection(
|
|
||||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
|
||||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.ON,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
val sentCommands = mutableListOf<AapCommand>()
|
|
||||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
|
|
||||||
|
|
||||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
|
||||||
|
|
||||||
nextSetting = settingPair(AapSetting.AncMode(
|
|
||||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
|
||||||
supported = supportedModes,
|
|
||||||
))
|
|
||||||
engine.processMessage(dummyMessage())
|
|
||||||
|
|
||||||
engine.state.value.pendingAncMode.shouldBeNull()
|
|
||||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
|
||||||
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ANC Debounce ────────────────────────────────────────
|
// ── ANC Debounce ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -645,7 +683,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
|
|||||||
every { decodeBattery(any()) } returns null
|
every { decodeBattery(any()) } returns null
|
||||||
every { decodePrivateKeyResponse(any()) } returns null
|
every { decodePrivateKeyResponse(any()) } returns null
|
||||||
every { decodeDeviceInfo(any()) } returns null
|
every { decodeDeviceInfo(any()) } returns null
|
||||||
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
|
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
|
||||||
|
current = AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
))
|
||||||
}
|
}
|
||||||
val engine = AapSessionEngine(profile, timeSource)
|
val engine = AapSessionEngine(profile, timeSource)
|
||||||
engine.start(this as TestScope)
|
engine.start(this as TestScope)
|
||||||
@@ -658,7 +698,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
|
|||||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||||
|
|
||||||
// Second message: unsolicited change — should be debounced
|
// Second message: unsolicited change — should be debounced
|
||||||
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
|
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
|
||||||
|
current = AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
))
|
||||||
engine.processMessage(dummyMessage())
|
engine.processMessage(dummyMessage())
|
||||||
|
|
||||||
// Not yet applied (debounced)
|
// Not yet applied (debounced)
|
||||||
@@ -731,7 +773,10 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
|
|||||||
@Test
|
@Test
|
||||||
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
|
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
val profile = mockProfile {
|
val profile = mockProfile {
|
||||||
every { decodeStemPress(any()) } returns StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT)
|
every { decodeStemPress(any()) } returns StemPressEvent(
|
||||||
|
StemPressEvent.PressType.SINGLE,
|
||||||
|
StemPressEvent.Bud.LEFT
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val engine = createEngine(profile)
|
val engine = createEngine(profile)
|
||||||
engine.startReady(this as TestScope)
|
engine.startReady(this as TestScope)
|
||||||
@@ -750,4 +795,4 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
|
|||||||
collector.cancel()
|
collector.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-6
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
@@ -52,7 +53,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
fun `enqueue ANC returns pendingAncMode in snapshot`() {
|
fun `enqueue ANC returns pendingAncMode in snapshot`() {
|
||||||
val coord = createCoordinator()
|
val coord = createCoordinator()
|
||||||
|
|
||||||
val result = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting())
|
val result =
|
||||||
|
coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting())
|
||||||
|
|
||||||
result.optimisticState.shouldBeNull()
|
result.optimisticState.shouldBeNull()
|
||||||
result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||||
@@ -96,7 +98,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state)
|
val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state)
|
||||||
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
|
val second =
|
||||||
|
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
|
||||||
|
|
||||||
second.snapshot.count shouldBe 2
|
second.snapshot.count shouldBe 2
|
||||||
}
|
}
|
||||||
@@ -133,7 +136,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
|
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
|
||||||
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
|
val second =
|
||||||
|
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
|
||||||
val result = coord.flush(second.pendingCommands)
|
val result = coord.flush(second.pendingCommands)
|
||||||
|
|
||||||
result.commands shouldHaveSize 2
|
result.commands shouldHaveSize 2
|
||||||
@@ -150,7 +154,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
|
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
|
||||||
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
|
val second =
|
||||||
|
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
|
||||||
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
|
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
|
||||||
val result = coord.flush(third.pendingCommands)
|
val result = coord.flush(third.pendingCommands)
|
||||||
|
|
||||||
@@ -296,4 +301,4 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
|||||||
check(mismatched) shouldBe false
|
check(mismatched) shouldBe false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-12
@@ -1,6 +1,5 @@
|
|||||||
package eu.darken.capod.pods.core.apple.aap
|
package eu.darken.capod.pods.core.apple.aap.engine
|
||||||
|
|
||||||
import eu.darken.capod.pods.core.apple.aap.HidTracker.HidFrameType
|
|
||||||
import io.kotest.matchers.collections.shouldBeEmpty
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
import io.kotest.matchers.collections.shouldContainExactly
|
import io.kotest.matchers.collections.shouldContainExactly
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
@@ -23,11 +22,11 @@ class HidTrackerTest : BaseTest() {
|
|||||||
fun `service directory frame from Pro 3 capture`() {
|
fun `service directory frame from Pro 3 capture`() {
|
||||||
val payload = hexToBytes(
|
val payload = hexToBytes(
|
||||||
"FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " +
|
"FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " +
|
||||||
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
|
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
|
||||||
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
|
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
|
||||||
)
|
)
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.ServiceDirectory>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceDirectory>()
|
||||||
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
|
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ class HidTrackerTest : BaseTest() {
|
|||||||
val fill = ByteArray(65) { 0xFF.toByte() }
|
val fill = ByteArray(65) { 0xFF.toByte() }
|
||||||
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
|
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
|
||||||
result.phase shouldBe 0x81
|
result.phase shouldBe 0x81
|
||||||
result.fill shouldBe 0xFF
|
result.fill shouldBe 0xFF
|
||||||
}
|
}
|
||||||
@@ -46,7 +45,7 @@ class HidTrackerTest : BaseTest() {
|
|||||||
val fill = ByteArray(65) { 0xEF.toByte() }
|
val fill = ByteArray(65) { 0xEF.toByte() }
|
||||||
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
|
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
|
||||||
result.phase shouldBe 0x02
|
result.phase shouldBe 0x02
|
||||||
result.fill shouldBe 0xEF
|
result.fill shouldBe 0xEF
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ class HidTrackerTest : BaseTest() {
|
|||||||
fun `terminator frame`() {
|
fun `terminator frame`() {
|
||||||
val payload = hexToBytes("00 04 00 00 01 00 FF")
|
val payload = hexToBytes("00 04 00 00 01 00 FF")
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.Terminator>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Terminator>()
|
||||||
result.payloadSize shouldBe 7
|
result.payloadSize shouldBe 7
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,20 +62,20 @@ class HidTrackerTest : BaseTest() {
|
|||||||
fun `short frame ending in FF but not 7 bytes is Other`() {
|
fun `short frame ending in FF but not 7 bytes is Other`() {
|
||||||
val payload = hexToBytes("00 04 00 FF")
|
val payload = hexToBytes("00 04 00 FF")
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.Other>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty payload is Other`() {
|
fun `empty payload is Other`() {
|
||||||
val result = HidTracker.classify(ByteArray(0))
|
val result = HidTracker.classify(ByteArray(0))
|
||||||
result.shouldBeInstanceOf<HidFrameType.Other>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `random payload is Other`() {
|
fun `random payload is Other`() {
|
||||||
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
|
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
|
||||||
val result = HidTracker.classify(payload)
|
val result = HidTracker.classify(payload)
|
||||||
result.shouldBeInstanceOf<HidFrameType.Other>()
|
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +210,4 @@ class HidTrackerTest : BaseTest() {
|
|||||||
logs.shouldBeEmpty()
|
logs.shouldBeEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user