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 95 additions and 16 deletions
Showing only changes of commit 714fe699f6 - Show all commits
@@ -6,6 +6,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class InfraredLearningCoordinator( class InfraredLearningCoordinator(
@@ -18,15 +19,11 @@ class InfraredLearningCoordinator(
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null, sdkKey: String? = null,
) { ): Job = externalScope.launch {
externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName, sdkKey) learnInfraredKey(profileId, deviceName, displayName, sdkKey)
} }
}
fun retry(ref: InfraredKeyRef) { fun retry(ref: InfraredKeyRef): Job = externalScope.launch {
externalScope.launch {
retryInfraredKeyLearning(ref) retryInfraredKeyLearning(ref)
} }
}
} }
@@ -148,7 +148,9 @@ 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) {
activeLearningSlotFlow.value = null
} else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
} }
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 -->
} }
@@ -162,15 +164,26 @@ class InfraredRemoteViewModel @Inject constructor(
} }
if (isLearningModeFlow.value) { if (isLearningModeFlow.value) {
Outdated
Review

加载卡死

启动学习后,activeLearningSlotFlow 只会被赋值,从未在学习成功、失败或关闭学习模式时清空,因此最后操作的按键会一直显示为加载中。与此同时,协调器丢弃了学习结果,Token 失效等错误也无法触发会话失效处理或错误提示。请根据用例结果或仓库中的按键状态清理活动槽位并处理错误。

**加载卡死** 启动学习后,`activeLearningSlotFlow` 只会被赋值,从未在学习成功、失败或关闭学习模式时清空,因此最后操作的按键会一直显示为加载中。与此同时,协调器丢弃了学习结果,Token 失效等错误也无法触发会话失效处理或错误提示。请根据用例结果或仓库中的按键状态清理活动槽位并处理错误。 <!-- pr-agent-dedup: 08cbe6405e17 --> <!-- pr-agent-key-issue-location: a26f8ce229dc -->
Outdated
Review

状态残留

开始学习时会设置 activeLearningSlotFlow,但该状态之后没有任何清空路径,也未关联学习成功或失败结果。完成一次学习后,对应按键将永久显示加载状态,即使关闭学习模式也不会恢复。请在协调器任务结束时根据结果清空该状态。

**状态残留** 开始学习时会设置 `activeLearningSlotFlow`,但该状态之后没有任何清空路径,也未关联学习成功或失败结果。完成一次学习后,对应按键将永久显示加载状态,即使关闭学习模式也不会恢复。请在协调器任务结束时根据结果清空该状态。 <!-- pr-agent-dedup: 643c68fe6ccb --> <!-- pr-agent-key-issue-location: 4c87bb7dcaf1 -->
if (activeLearningSlotFlow.value != null) {
return
}
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn( val job = learningCoordinator.learn(
Outdated
Review

无法重学

学习分支不区分槽位是否已有记录,始终调用 learningCoordinator.learn。仓库明确拒绝同一 SDK 槽位的重复绑定,因此用户重新录制 READY 或 FAILED 按键时会失败,尤其 FAILED 按键在此界面没有可用的恢复路径。应根据 learnedSlotMap 对已有失败项调用重试流程,或显式替换现有绑定。

**无法重学** 学习分支不区分槽位是否已有记录,始终调用 `learningCoordinator.learn`。仓库明确拒绝同一 SDK 槽位的重复绑定,因此用户重新录制 READY 或 FAILED 按键时会失败,尤其 FAILED 按键在此界面没有可用的恢复路径。应根据 `learnedSlotMap` 对已有失败项调用重试流程,或显式替换现有绑定。 <!-- pr-agent-dedup: 347060413a4b --> <!-- pr-agent-key-issue-location: 36abc05cf3f2 -->
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
displayName = spec.displayName, displayName = spec.displayName,
sdkKey = spec.defaultSlot, sdkKey = spec.defaultSlot,
) )
job.invokeOnCompletion {
if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null
}
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else { } else {
Outdated
Review

设备竞态

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

**设备竞态** `savedDeviceName` 尚未从持久化配置加载时会直接回退到 `DEFAULT_DEVICE_NAME`。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 `ir-1`,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。 <!-- pr-agent-dedup: 20ea2fda4715 --> <!-- pr-agent-key-issue-location: 702140543860 -->
if (sendingKeySlotFlow.value != null) {
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) {
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 -->
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
Outdated
Review

并发冲突

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

**并发冲突** 发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。 <!-- pr-agent-dedup: 69d5674e86b4 --> <!-- pr-agent-key-issue-location: f2db1b110ebc -->
@@ -108,6 +108,7 @@ fun RemoteCircleButton(
color = defaultContainer, color = defaultContainer,
contentColor = defaultContent, contentColor = defaultContent,
shadowElevation = if (isLearned) 4.dp else 1.dp, shadowElevation = if (isLearned) 4.dp else 1.dp,
enabled = !isLoading,
onClick = onClick, onClick = onClick,
) { ) {
Box( Box(
@@ -174,6 +175,7 @@ fun RemoteRectButton(
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
shadowElevation = if (isLearned) 3.dp else 1.dp, shadowElevation = if (isLearned) 3.dp else 1.dp,
enabled = !isLoading,
onClick = onClick, onClick = onClick,
) { ) {
Box( Box(
@@ -240,7 +242,7 @@ fun VerticalCapsuleRocker(
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent, else Color.Transparent,
) )
.clickable(onClick = onUpClick) .clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"), .testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -267,7 +269,7 @@ fun VerticalCapsuleRocker(
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent, else Color.Transparent,
) )
.clickable(onClick = onDownClick) .clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"), .testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -338,7 +340,12 @@ fun RemoteDpadWheel(
.padding(top = 10.dp) .padding(top = 10.dp)
.size(54.dp) .size(54.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onUpClick) .background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"), .testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -353,7 +360,12 @@ fun RemoteDpadWheel(
.padding(bottom = 10.dp) .padding(bottom = 10.dp)
.size(54.dp) .size(54.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onDownClick) .background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"), .testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -368,7 +380,12 @@ fun RemoteDpadWheel(
.padding(start = 10.dp) .padding(start = 10.dp)
.size(54.dp) .size(54.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onLeftClick) .background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isLeftLoading, onClick = onLeftClick)
.testTag("remote_button_${leftSpec.id}"), .testTag("remote_button_${leftSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -383,7 +400,12 @@ fun RemoteDpadWheel(
.padding(end = 10.dp) .padding(end = 10.dp)
.size(54.dp) .size(54.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onRightClick) .background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isRightLoading, onClick = onRightClick)
.testTag("remote_button_${rightSpec.id}"), .testTag("remote_button_${rightSpec.id}"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -133,6 +133,53 @@ class InfraredRemoteViewModelTest {
assertEquals(1, gateway.learnedKeys.size) assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun disablingLearningModeClearsActiveLearningSlot() = 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)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
job.cancel() job.cancel()
} }