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
6 changed files with 99 additions and 24 deletions
Showing only changes of commit 8682f81cb8 - Show all commits
@@ -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]
val job = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
} else {
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
return
}
activeLearningJob = job
job.invokeOnCompletion {
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) {
Review

设备错绑

配置尚未加载时,savedDeviceName 为 null,这里会直接回退到 ir-1,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。

**设备错绑** 配置尚未加载时,`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 = "红外遥控器",
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()
}