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 66 additions and 8 deletions
Showing only changes of commit a3bcdd973c - Show all commits
@@ -28,6 +28,7 @@ class RoomInfraredKeyRepository(
DomainResult.Success(entities.map { it.toDomain() }) DomainResult.Success(entities.map { it.toDomain() })
} }
.catch { throwable -> .catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
} }
} }
@@ -38,6 +39,7 @@ class RoomInfraredKeyRepository(
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) }) DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
} }
.catch { throwable -> .catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
} }
} }
@@ -182,6 +184,9 @@ class RoomInfraredKeyRepository(
} }
private fun mapThrowableToDomainError(throwable: Throwable): DomainError { private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
if (throwable is kotlinx.coroutines.CancellationException) {
throw throwable
}
return when (throwable) { return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation") is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed") else -> DomainError.Unknown(detail = "Database operation failed")
@@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -68,6 +69,7 @@ class InfraredRemoteViewModel @Inject constructor(
private val sendingKeySlotFlow = MutableStateFlow<String?>(null) private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null) private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null) private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> = private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode -> runtimeModeStore.mode.flatMapLatest { mode ->
@@ -149,6 +151,8 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) { fun toggleLearningMode(enabled: Boolean) {
Outdated
Review

任务未取消

关闭学习模式时仅清空 UI 槽位,没有保存或取消正在运行的学习任务。用户在录制过程中关闭开关后,底层学习仍会继续,而界面允许发送或再次开始学习;若再次学习同一槽位,旧任务的完成回调还可能提前清除新任务的加载状态。应持有并取消当前任务,或在任务真正结束前保持活动状态。

**任务未取消** 关闭学习模式时仅清空 UI 槽位,没有保存或取消正在运行的学习任务。用户在录制过程中关闭开关后,底层学习仍会继续,而界面允许发送或再次开始学习;若再次学习同一槽位,旧任务的完成回调还可能提前清除新任务的加载状态。应持有并取消当前任务,或在任务真正结束前保持活动状态。 <!-- pr-agent-dedup: 67a90f12e304 --> <!-- pr-agent-key-issue-location: f63601c36e3b -->
isLearningModeFlow.value = enabled isLearningModeFlow.value = enabled
if (!enabled) { if (!enabled) {
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
} else { } else {
Outdated
Review

取消失效

关闭学习模式时取消的只是等待结果的 ViewModel Job,而 learn/retry 返回的任务由独立的应用级 scope 执行;取消 await 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。

**取消失效** 关闭学习模式时取消的只是等待结果的 ViewModel Job,而 `learn`/`retry` 返回的任务由独立的应用级 scope 执行;取消 `await` 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。 <!-- pr-agent-dedup: 42f163fda940 --> <!-- pr-agent-key-issue-location: bc00d351aea6 -->
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -168,17 +172,26 @@ class InfraredRemoteViewModel @Inject constructor(
return return
} }
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val job = learningCoordinator.learn( val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
val job = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
} else {
learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
Outdated
Review

设备竞态

savedDeviceName 尚未从持久化配置加载时会直接回退到 DEFAULT_DEVICE_NAME。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 ir-1,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。

**设备竞态** `savedDeviceName` 尚未从持久化配置加载时会直接回退到 `DEFAULT_DEVICE_NAME`。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 `ir-1`,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。 <!-- pr-agent-dedup: 20ea2fda4715 --> <!-- pr-agent-key-issue-location: 702140543860 -->
displayName = spec.displayName, displayName = spec.displayName,
sdkKey = spec.defaultSlot, sdkKey = spec.defaultSlot,
Outdated
Review

重学失败

existingKey 只要非空就会调用 learningCoordinator.retry,但该重试路径用于失败按键,而学习模式也允许点击已经处于 READY 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。

**重学失败** `existingKey` 只要非空就会调用 `learningCoordinator.retry`,但该重试路径用于失败按键,而学习模式也允许点击已经处于 `READY` 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。 <!-- pr-agent-dedup: 365d84abc324 --> <!-- pr-agent-key-issue-location: 6a2d13d13315 -->
Outdated
Review

无法重录

只要槽位已存在就调用 learningCoordinator.retry,其中也包括状态为 READY 的已学习按键。但重试流程用于失败按键,设置页也只对 FAILED 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。

**无法重录** 只要槽位已存在就调用 `learningCoordinator.retry`,其中也包括状态为 `READY` 的已学习按键。但重试流程用于失败按键,设置页也只对 `FAILED` 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。 <!-- pr-agent-dedup: 6de2ba8f0bdf --> <!-- pr-agent-key-issue-location: b2730b9541cd -->
) )
Outdated
Review

并发冲突

发送信号期间仍可开启学习模式,而学习分支没有检查 sendingKeySlotFlow。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。

**并发冲突** 发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。 <!-- pr-agent-dedup: 69d5674e86b4 --> <!-- pr-agent-key-issue-location: f2db1b110ebc -->
}
activeLearningJob = job
job.invokeOnCompletion { job.invokeOnCompletion {
if (activeLearningJob === job) {
activeLearningJob = null
if (activeLearningSlotFlow.value == spec.defaultSlot) { if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
Outdated
Review

重复发射

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

**重复发射** 发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。 <!-- pr-agent-dedup: fede3506d9e3 --> <!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
} }
} }
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else { } else {
if (sendingKeySlotFlow.value != null) { if (sendingKeySlotFlow.value != null) {
@@ -207,6 +220,12 @@ class InfraredRemoteViewModel @Inject constructor(
} }
} }
override fun onCleared() {
super.onCleared()
activeLearningJob?.cancel()
activeLearningJob = null
}
fun clearMessage() { fun clearMessage() {
messageFlow.value = null messageFlow.value = null
} }
@@ -52,6 +52,7 @@ class DataStoreInfraredRuntimeModeStore(
} }
DomainResult.Success(Unit) DomainResult.Success(Unit)
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
} }
} }
@@ -183,4 +183,37 @@ class InfraredRemoteViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(failedKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
job.cancel()
}
} }