feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
4 changed files with 117 additions and 56 deletions
Showing only changes of commit 8cd44c93e1 - Show all commits
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -73,33 +74,40 @@ class InfraredRemoteViewModel @Inject constructor(
private var activeLearningJob: Job? = null private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> = private data class InfraredRemoteConfigSnapshot(
val effectiveProfileId: ProviderProfileId? = null,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
)
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
runtimeModeStore.mode.flatMapLatest { mode -> runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) { val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) }
Outdated
Review

恢复错位

failInterruptedLearning 使用原始认证配置,而按键数据使用经运行模式解析后的 effectiveProfileId。在 FAKE 模式下,按键通常存储于派生配置(如 abox:infrared-fake);进程中断后,这些 LEARNING 记录不会被标记为失败,而 onButtonClick 又会直接忽略已有的 LEARNING 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。

**恢复错位** `failInterruptedLearning` 使用原始认证配置,而按键数据使用经运行模式解析后的 `effectiveProfileId`。在 FAKE 模式下,按键通常存储于派生配置(如 `abox:infrared-fake`);进程中断后,这些 `LEARNING` 记录不会被标记为失败,而 `onButtonClick` 又会直接忽略已有的 `LEARNING` 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。 <!-- pr-agent-dedup: fe03f4d5ccbe --> <!-- pr-agent-key-issue-location: 97afcff45f2a -->
flowOf(Pair(null, mode)) if (profile == null) {
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
} else { } else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) { val deviceName = (deviceResult as? DomainResult.Success)?.value
Outdated
Review

错误吞没

设备配置和按键观察流中的 DomainResult.Failure 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。

**错误吞没** 设备配置和按键观察流中的 `DomainResult.Failure` 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。 <!-- pr-agent-dedup: f7ef39caa7e5 --> <!-- pr-agent-key-issue-location: babb0d2f9517 -->
is DomainResult.Success -> { if (deviceName == null) {
val device = deviceResult.value flowOf(
if (device == null) flowOf(DomainResult.Success(emptyList())) InfraredRemoteConfigSnapshot(
else keyRepository.observeKeys(profile, device) effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = null,
learnedSlotMap = emptyMap(),
),
)
} else {
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName,
learnedSlotMap = keys.associateBy { it.sdkKey },
)
} }
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
} }
} }
} }
@@ -108,34 +116,18 @@ class InfraredRemoteViewModel @Inject constructor(
val state: StateFlow<InfraredRemoteUiState> = combine( val state: StateFlow<InfraredRemoteUiState> = combine(
Outdated
Review

状态错配

effectiveProfileAndMode、deviceNameFlow 和 keysFlow 被独立组合,设备名或运行模式切换时可能短暂生成“新配置 + 旧按键列表”的状态。用户此时点击按键,会使用旧按键引用向旧红外设备发送命令,或用新 profile 查询旧按键。应在同一个 flatMapLatest 中生成相互关联的 profile、设备和按键快照,或发送前校验按键的 profile、deviceName 与当前配置一致。

**状态错配** `effectiveProfileAndMode`、`deviceNameFlow` 和 `keysFlow` 被独立组合,设备名或运行模式切换时可能短暂生成“新配置 + 旧按键列表”的状态。用户此时点击按键,会使用旧按键引用向旧红外设备发送命令,或用新 profile 查询旧按键。应在同一个 `flatMapLatest` 中生成相互关联的 profile、设备和按键快照,或发送前校验按键的 profile、deviceName 与当前配置一致。 <!-- pr-agent-dedup: 0c88096cf35a --> <!-- pr-agent-key-issue-location: 6aab6621dcad -->
sceneFlow, sceneFlow,
isLearningModeFlow, isLearningModeFlow,
effectiveProfileAndMode, configSnapshotFlow,
deviceNameFlow, combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
keysFlow, Triple(sending, learning, msg)
sendingKeySlotFlow, },
activeLearningSlotFlow, ) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
messageFlow,
) { args: Array<Any?> ->
val scene = args[0] as RemoteScene
val isLearning = args[1] as Boolean
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
val deviceResult = args[3] as DomainResult<DeviceName?>
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
val sendingSlot = args[5] as String?
val learningSlot = args[6] as String?
val msg = args[7] as RemoteUiMessage?
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState( InfraredRemoteUiState(
selectedScene = scene, selectedScene = scene,
isLearningMode = isLearning, isLearningMode = isLearning,
runtimeMode = mode, runtimeMode = snapshot.runtimeMode,
effectiveProfileId = profile, effectiveProfileId = snapshot.effectiveProfileId,
savedDeviceName = savedDevice, savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = slotMap, learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot, sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot, activeLearningSlot = learningSlot,
message = msg, message = msg,
@@ -158,6 +150,11 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile ->
viewModelScope.launch(kotlinx.coroutines.NonCancellable) {
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
}
}
} else { } else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
} }
@@ -179,6 +176,7 @@ class InfraredRemoteViewModel @Inject constructor(
if (existingKey?.status == InfraredKeyStatus.LEARNING) { if (existingKey?.status == InfraredKeyStatus.LEARNING) {
Outdated
Review

重复发射

发射过程中没有检查已有的 sendingKeySlotFlow,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。

**重复发射** 发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。 <!-- pr-agent-dedup: fede3506d9e3 --> <!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
return return
} }
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val deferred = if (existingKey != null) { val deferred = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
@@ -223,13 +221,12 @@ class InfraredRemoteViewModel @Inject constructor(
} }
} }
activeLearningJob = job activeLearningJob = job
Outdated
Review

消息覆盖

“正在录制”消息在启动等待任务之后写入。如果协调器返回已完成的 Deferred,例如 FAKE 模式立即完成,viewModelScope.launch 可能先同步写入成功、失败或 SessionInvalid,随后又被该录制消息覆盖;认证失效时甚至不会触发退出。请在启动任务前设置录制消息。

**消息覆盖** “正在录制”消息在启动等待任务之后写入。如果协调器返回已完成的 `Deferred`,例如 FAKE 模式立即完成,`viewModelScope.launch` 可能先同步写入成功、失败或 `SessionInvalid`,随后又被该录制消息覆盖;认证失效时甚至不会触发退出。请在启动任务前设置录制消息。 <!-- pr-agent-dedup: a62768f2c90c --> <!-- pr-agent-key-issue-location: 4c7d8e5d15f8 -->
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else { } else {
if (sendingKeySlotFlow.value != null) { if (sendingKeySlotFlow.value != null) {
return return
} }
val key = state.value.learnedSlotMap[spec.defaultSlot] val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) { if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return return
} }
@@ -253,4 +253,21 @@ class InfraredRemoteViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
advanceUntilIdle()
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
} }
@@ -5,6 +5,9 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class LearnInfraredKey( class LearnInfraredKey(
private val repository: InfraredKeyRepository, private val repository: InfraredKeyRepository,
@@ -28,13 +31,27 @@ class LearnInfraredKey(
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
} }
return when ( val result = try {
val result = gateway.learnKey( gateway.learnKey(
profileId = key.profileId, profileId = key.profileId,
deviceName = key.deviceName, deviceName = key.deviceName,
key = key.sdkKey, key = key.sdkKey,
) )
) { } catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> when ( is DomainResult.Success -> when (
val transition = repository.transitionStatus( val transition = repository.transitionStatus(
ref = key.ref(), ref = key.ref(),
@@ -101,13 +118,27 @@ class RetryInfraredKeyLearning(
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
} }
return when ( val result = try {
val result = gateway.learnKey( gateway.learnKey(
profileId = key.profileId, profileId = key.profileId,
deviceName = key.deviceName, deviceName = key.deviceName,
key = key.sdkKey, key = key.sdkKey,
) )
) { } catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus( is DomainResult.Success -> repository.transitionStatus(
ref = ref, ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING), expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
@@ -269,6 +269,22 @@ class LearnInfraredKeyTest {
assertEquals(1, gateway.learnedKeys.size) assertEquals(1, gateway.learnedKeys.size)
} }
@Test
fun learningCancellationTransitionsStatusToFailed() = runTest {
gateway.onLearn = { _, _, _ ->
throw kotlinx.coroutines.CancellationException("User cancelled")
}
try {
learn(profile, device, "Power")
} catch (_: kotlinx.coroutines.CancellationException) {
// expected
}
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("Cancelled", repository.onlyKey().lastErrorDetail)
}
private fun failedKey( private fun failedKey(
id: InfraredKeyId = keyId, id: InfraredKeyId = keyId,
sdkKey: String = "app_ir_key1", sdkKey: String = "app_ir_key1",