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 package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName 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.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef 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.Deferred
import kotlinx.coroutines.launch import kotlinx.coroutines.async
class InfraredLearningCoordinator( class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey, private val learnInfraredKey: LearnInfraredKey,
@@ -19,11 +21,11 @@ class InfraredLearningCoordinator(
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null, sdkKey: String? = null,
): Job = externalScope.launch { ): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
learnInfraredKey(profileId, deviceName, displayName, sdkKey) learnInfraredKey(profileId, deviceName, displayName, sdkKey)
} }
fun retry(ref: InfraredKeyRef): Job = externalScope.launch { fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
retryInfraredKeyLearning(ref) retryInfraredKeyLearning(ref)
} }
} }
@@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor(
if (activeLearningSlotFlow.value != null) { if (activeLearningSlotFlow.value != null) {
return return
} }
activeLearningSlotFlow.value = spec.defaultSlot
val existingKey = state.value.learnedSlotMap[spec.defaultSlot] val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
val job = if (existingKey != null) { if (existingKey?.status == InfraredKeyStatus.LEARNING) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) return
}
activeLearningSlotFlow.value = spec.defaultSlot
val job = viewModelScope.launch {
try {
val result = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await()
} else { } else {
learningCoordinator.learn( when (val res = learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
displayName = spec.displayName, displayName = spec.displayName,
sdkKey = spec.defaultSlot, sdkKey = spec.defaultSlot,
) ).await()) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> DomainResult.Failure(res.error)
} }
activeLearningJob = job }
job.invokeOnCompletion { when (result) {
if (activeLearningJob === job) { is DomainResult.Success -> {
activeLearningJob = null 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) { 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 activeLearningSlotFlow.value = null
} }
} }
} }
activeLearningJob = job
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) {
@@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface 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.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "红外遥控器", text = stringResource(R.string.infrared_remote_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
) )
}, },
navigationIcon = { navigationIcon = {
TextButton(onClick = onBack) { TextButton(onClick = onBack) {
Text("返回") Text(stringResource(R.string.infrared_back))
} }
}, },
actions = { actions = {
@@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( 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, fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
@@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", text = stringResource(R.string.infrared_remote_learning_banner),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen(
FilterChip( FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text("❄️ 空调遥控") }, label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
modifier = Modifier modifier = Modifier
.padding(end = 8.dp) .padding(end = 8.dp)
.testTag("scene_tab_ac"), .testTag("scene_tab_ac"),
@@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen(
FilterChip( FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION, selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) }, onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text("📺 电视遥控") }, label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
modifier = Modifier.testTag("scene_tab_tv"), modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">取消</string> <string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string> <string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</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> </resources>
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">Cancel</string> <string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string> <string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</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> </resources>
@@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest {
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) 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() job.cancel()
} }