feat(infrared): add infrared control, learning, and virtual remote module #9
+6
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-14
@@ -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
|
||||||
} else {
|
|
||||||
learningCoordinator.learn(
|
|
||||||
profileId = currentProfile,
|
|
||||||
deviceName = savedDevice,
|
|
||||||
displayName = spec.displayName,
|
|
||||||
sdkKey = spec.defaultSlot,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
activeLearningJob = job
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
job.invokeOnCompletion {
|
val job = viewModelScope.launch {
|
||||||
if (activeLearningJob === job) {
|
try {
|
||||||
activeLearningJob = null
|
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) {
|
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
||||||
|
|
|||||||
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) {
|
||||||
|
|||||||
+12
-6
@@ -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>
|
||||||
|
|||||||
+37
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
设备错绑
配置尚未加载时,
savedDeviceName为null,这里会直接回退到ir-1,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。