feat(infrared): add infrared control, learning, and virtual remote module #9
@@ -1,13 +1,15 @@
|
||||
package com.flagship.abox.manager.infrared.application
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
class InfraredLearningCoordinator(
|
||||
private val learnInfraredKey: LearnInfraredKey,
|
||||
@@ -19,11 +21,11 @@ class InfraredLearningCoordinator(
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): Job = externalScope.launch {
|
||||
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
|
||||
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
||||
}
|
||||
|
||||
fun retry(ref: InfraredKeyRef): Job = externalScope.launch {
|
||||
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||
retryInfraredKeyLearning(ref)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
if (activeLearningSlotFlow.value != null) {
|
||||
return
|
||||
}
|
||||
|
|
||||
activeLearningSlotFlow.value = spec.defaultSlot
|
||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
||||
|
pr-agent-bot
commented
重学失败
**重学失败**
`existingKey` 只要非空就会调用 `learningCoordinator.retry`,但该重试路径用于失败按键,而学习模式也允许点击已经处于 `READY` 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。
<!-- pr-agent-dedup: 365d84abc324 -->
<!-- pr-agent-key-issue-location: 6a2d13d13315 -->
pr-agent-bot
commented
无法重录 只要槽位已存在就调用 **无法重录**
只要槽位已存在就调用 `learningCoordinator.retry`,其中也包括状态为 `READY` 的已学习按键。但重试流程用于失败按键,设置页也只对 `FAILED` 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。
<!-- pr-agent-dedup: 6de2ba8f0bdf -->
<!-- pr-agent-key-issue-location: b2730b9541cd -->
|
||||
val job = if (existingKey != null) {
|
||||
|
pr-agent-bot
commented
并发冲突 发送信号期间仍可开启学习模式,而学习分支没有检查 **并发冲突**
发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。
<!-- pr-agent-dedup: 69d5674e86b4 -->
<!-- pr-agent-key-issue-location: f2db1b110ebc -->
|
||||
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
} else {
|
||||
learningCoordinator.learn(
|
||||
|
pr-agent-bot
commented
重复发射 发射过程中没有检查已有的 **重复发射**
发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。
<!-- pr-agent-dedup: fede3506d9e3 -->
<!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
|
||||
profileId = currentProfile,
|
||||
deviceName = savedDevice,
|
||||
displayName = spec.displayName,
|
||||
sdkKey = spec.defaultSlot,
|
||||
)
|
||||
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
|
||||
return
|
||||
}
|
||||
activeLearningJob = job
|
||||
job.invokeOnCompletion {
|
||||
|
pr-agent-bot
commented
错误丢失 学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 **错误丢失**
学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 `handleDomainError` 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 `onSessionInvalid`;应由协调器返回或发布结果,并将错误映射为对应的 `RemoteUiMessage`。
<!-- pr-agent-dedup: 16b03ab5c738 -->
<!-- pr-agent-key-issue-location: 392b62285515 -->
pr-agent-bot
commented
竞态条件 关闭学习模式时会异步触发按 profile 清理全部 **竞态条件**
关闭学习模式时会异步触发按 profile 清理全部 `LEARNING` 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 `FAILED`,导致随后成功的学习无法转换为 `READY`;初始化和 `onCleared` 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。
<!-- pr-agent-dedup: 943572f8ab5b -->
<!-- pr-agent-key-issue-location: e2acde597e7e -->
|
||||
if (activeLearningJob === job) {
|
||||
activeLearningJob = null
|
||||
activeLearningSlotFlow.value = spec.defaultSlot
|
||||
val job = viewModelScope.launch {
|
||||
try {
|
||||
val result = if (existingKey != null) {
|
||||
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await()
|
||||
} else {
|
||||
when (val res = learningCoordinator.learn(
|
||||
profileId = currentProfile,
|
||||
deviceName = savedDevice,
|
||||
displayName = spec.displayName,
|
||||
sdkKey = spec.defaultSlot,
|
||||
).await()) {
|
||||
is DomainResult.Success -> DomainResult.Success(Unit)
|
||||
is DomainResult.Failure -> DomainResult.Failure(res.error)
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功")
|
||||
}
|
||||
is DomainResult.Failure -> {
|
||||
handleDomainError(result.error)
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} finally {
|
||||
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
||||
|
pr-agent-bot
commented
设备错绑 配置尚未加载时, **设备错绑**
配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。
<!-- pr-agent-dedup: 64f51ac400f4 -->
<!-- pr-agent-key-issue-location: 40951f9578b1 -->
|
||||
activeLearningSlotFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
activeLearningJob = job
|
||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||
} else {
|
||||
if (sendingKeySlotFlow.value != null) {
|
||||
|
||||
@@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
||||
@@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "红外遥控器",
|
||||
|
pr-agent-bot
commented
本地化缺失 遥控器标题、返回按钮以及该页面的其他提示和场景标签都直接写死为中文。应用运行在非中文区域设置下仍会显示中文,也无法利用本次新增的字符串资源;应统一改用 **本地化缺失**
遥控器标题、返回按钮以及该页面的其他提示和场景标签都直接写死为中文。应用运行在非中文区域设置下仍会显示中文,也无法利用本次新增的字符串资源;应统一改用 `stringResource`。
<!-- pr-agent-dedup: 362d988ef914 -->
<!-- pr-agent-key-issue-location: 132ce4939c22 -->
|
||||
text = stringResource(R.string.infrared_remote_title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("返回")
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (state.isLearningMode) "录制中" else "学习模式",
|
||||
text = if (state.isLearningMode) {
|
||||
stringResource(R.string.infrared_remote_recording)
|
||||
} else {
|
||||
stringResource(R.string.infrared_remote_learning_mode)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
@@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "🔴 红外学习模式已开启:点击任意按键即可录制信号",
|
||||
text = stringResource(R.string.infrared_remote_learning_banner),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen(
|
||||
FilterChip(
|
||||
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
|
||||
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
|
||||
label = { Text("❄️ 空调遥控") },
|
||||
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.testTag("scene_tab_ac"),
|
||||
@@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen(
|
||||
FilterChip(
|
||||
selected = state.selectedScene == RemoteScene.TELEVISION,
|
||||
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
|
||||
label = { Text("📺 电视遥控") },
|
||||
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
|
||||
modifier = Modifier.testTag("scene_tab_tv"),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
|
||||
@@ -32,4 +32,11 @@
|
||||
<string name="infrared_dialog_cancel">取消</string>
|
||||
<string name="infrared_dialog_confirm">确认</string>
|
||||
<string name="infrared_back">返回</string>
|
||||
<string name="infrared_remote_title">红外遥控器</string>
|
||||
<string name="infrared_remote_recording">录制中</string>
|
||||
<string name="infrared_remote_learning_mode">学习模式</string>
|
||||
<string name="infrared_remote_learning_banner">🔴 红外学习模式已开启:点击任意按键即可录制信号</string>
|
||||
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
|
||||
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
|
||||
<string name="infrared_remote_settings">设置</string>
|
||||
</resources>
|
||||
|
||||
@@ -32,4 +32,11 @@
|
||||
<string name="infrared_dialog_cancel">Cancel</string>
|
||||
<string name="infrared_dialog_confirm">Confirm</string>
|
||||
<string name="infrared_back">Back</string>
|
||||
<string name="infrared_remote_title">Infrared Remote</string>
|
||||
<string name="infrared_remote_recording">Recording</string>
|
||||
<string name="infrared_remote_learning_mode">Learn Mode</string>
|
||||
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
|
||||
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
||||
<string name="infrared_remote_scene_tv">📺 Television</string>
|
||||
<string name="infrared_remote_settings">Settings</string>
|
||||
</resources>
|
||||
|
||||
@@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest {
|
||||
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)
|
||||
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(fakeProfile, device)
|
||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(fakeProfile, device)
|
||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
设备竞态
savedDeviceName尚未从持久化配置加载时会直接回退到DEFAULT_DEVICE_NAME。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给ir-1,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。